diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..59c2f95d --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Pre-commit hook: Fast formatting check before creating commits. +set -e + +echo "==> Running cargo fmt --all --check..." +cargo fmt --all --check diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 00000000..4e978906 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Pre-push hook: Comprehensive validation before pushing to remote. +set -e + +echo "==> Checking headless gateway (AGENTS.md rule)..." +cargo check -p gateway --no-default-features + +echo "==> Running Clippy on workspace..." +cargo clippy --workspace --exclude workshop --exclude workshop-server --all-targets --all-features -- -D warnings + +if command -v cargo-deny >/dev/null 2>&1; then + echo "==> Running cargo deny check..." + cargo deny check +fi + +echo "==> Pre-push checks passed successfully." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d394e9e6..5381ed83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,14 @@ jobs: with: components: rustfmt, clippy + - uses: dtolnay/rust-toolchain@1.89.0 + + # cargo-public-api 0.52.0 requires nightly rustdoc JSON. Keep this exact + # and in sync with the explicit +toolchain invocation in the driver. + - uses: dtolnay/rust-toolchain@nightly + with: + toolchain: nightly-2026-09-05 + - name: Cache cargo uses: Swatinem/rust-cache@v2 @@ -41,14 +49,38 @@ jobs: with: node-version: 22 - - name: Install UI dependencies - working-directory: crates/workshop-server/ui - run: npm ci + - name: Test integration test ceiling driver + run: node tools/check-integration-test-ceilings.test.mjs + + - name: Check integration test ceilings + run: node tools/check-integration-test-ceilings.mjs + + - name: Install architecture tools + run: | + RUSTUP_TOOLCHAIN=1.89.0 cargo install cargo-modules --version 0.25.0 --locked + RUSTUP_TOOLCHAIN=1.89.0 cargo install cargo-public-api --version 0.52.0 --locked - name: Install config UI dependencies working-directory: crates/gateway-config-ui/ui run: npm ci + - name: Build Gateway without Workshop UI tooling + run: cargo build --locked -p gateway + + - name: Test STT architecture driver + run: node --test tools/check-stt-architecture.test.mjs + + - name: Check STT module and public API architecture + run: node tools/check-stt-architecture.mjs + + - name: Check production gateway-stt warnings + env: + RUSTFLAGS: -D warnings + run: cargo check --locked -p gateway-stt --lib + + - name: Check STT Cargo and ceiling architecture + run: cargo test -p gateway-stt --test it architecture + - name: Format run: cargo fmt --all --check @@ -108,12 +140,22 @@ jobs: working-directory: crates/workshop-server/ui run: npm ci + - name: Build featureless Gateway + run: cargo build --locked -p gateway --no-default-features + + - name: Stage Gateway sidecar + run: node tools/stage-gateway-sidecar.mjs stage --target x86_64-pc-windows-msvc --source target/debug/promptforge-gateway.exe + - name: Clippy (workshop) run: cargo clippy -p workshop -p workshop-server --all-targets -- -D warnings - name: Test (workshop) run: cargo test --locked -p workshop -p workshop-server + - name: Remove Gateway sidecar + if: always() + run: node tools/stage-gateway-sidecar.mjs remove --target x86_64-pc-windows-msvc + - name: Clean tree shell: bash run: | @@ -156,9 +198,19 @@ jobs: working-directory: crates/workshop-server/ui run: npm ci + - name: Build featureless Gateway + run: cargo build --locked -p gateway --no-default-features + + - name: Stage Gateway sidecar + run: node tools/stage-gateway-sidecar.mjs stage --target x86_64-unknown-linux-gnu --source target/debug/promptforge-gateway + - name: Build (workshop, Linux) run: cargo build --locked -p workshop + - name: Remove Gateway sidecar + if: always() + run: node tools/stage-gateway-sidecar.mjs remove --target x86_64-unknown-linux-gnu + - name: Clean tree shell: bash run: | diff --git a/.github/workflows/dist-ci/build-setup.yml b/.github/workflows/dist-ci/build-setup.yml index 2a5b8d56..356b7f0c 100644 --- a/.github/workflows/dist-ci/build-setup.yml +++ b/.github/workflows/dist-ci/build-setup.yml @@ -8,6 +8,8 @@ uses: actions/setup-node@v4 with: node-version: 22 + cache: npm + cache-dependency-path: crates/*/ui/package-lock.json - name: Install UI dependencies run: npm ci --prefix crates/workshop-server/ui - name: Install config UI dependencies diff --git a/.github/workflows/gateway-release-test.yml b/.github/workflows/gateway-release-test.yml index 0e583b32..a2794067 100644 --- a/.github/workflows/gateway-release-test.yml +++ b/.github/workflows/gateway-release-test.yml @@ -47,7 +47,7 @@ jobs: run: | workdir="$(mktemp -d)" printf 'config-version = 2\n[server]\nbind = "127.0.0.1:8081"\napi_key = "test-key"\n\n[[profile]]\nname = "main"\nmodels = []\n' > "$workdir/gateway.toml" - promptforge-gateway serve "$workdir/gateway.toml" --profile main & + promptforge-gateway --config "$workdir/gateway.toml" --profile main & server_pid=$! trap 'kill $server_pid 2>/dev/null || true' EXIT for attempt in $(seq 1 30); do diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 98b68b32..0b48d917 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -66,6 +66,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 + cache: npm + cache-dependency-path: crates/*/ui/package-lock.json - name: Install UI dependencies run: | npm ci --prefix crates/workshop-server/ui @@ -97,6 +99,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 + cache: npm + cache-dependency-path: crates/*/ui/package-lock.json - name: Install UI dependencies run: | npm ci --prefix crates/workshop-server/ui @@ -157,6 +161,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 + cache: npm + cache-dependency-path: crates/*/ui/package-lock.json - name: Install UI dependencies working-directory: crates/workshop-server/ui diff --git a/.github/workflows/release-workshop.yml b/.github/workflows/release-workshop.yml index e66b2918..5d97e760 100644 --- a/.github/workflows/release-workshop.yml +++ b/.github/workflows/release-workshop.yml @@ -66,6 +66,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 + cache: npm + cache-dependency-path: crates/*/ui/package-lock.json - name: Install UI dependencies working-directory: crates/workshop-server/ui @@ -88,7 +90,7 @@ jobs: # xdg-utils: the AppImage bundler shells out to xdg-open, which the # ARM runner images do not preinstall (the x64 image does). # https://github.com/tauri-apps/tauri-action/issues/1319 - sudo apt-get install -y libwebkit2gtk-4.1-dev libssl-dev librsvg2-dev xdg-utils + sudo apt-get install -y --no-install-recommends libwebkit2gtk-4.1-dev libssl-dev librsvg2-dev xdg-utils # SIGNING (Windows): import the Authenticode certificate here once an # EV/OV cert exists; tauri-action picks it up from the machine store. diff --git a/.github/workflows/stt-miri.yml b/.github/workflows/stt-miri.yml new file mode 100644 index 00000000..aafec973 --- /dev/null +++ b/.github/workflows/stt-miri.yml @@ -0,0 +1,336 @@ +name: STT Miri + +on: + push: + branches: [master, main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + pure-stt-state: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@nightly + with: + toolchain: nightly-2026-09-05 + components: miri + + - name: Cache Cargo and Miri + uses: Swatinem/rust-cache@v2 + + - name: Prepare Miri + run: cargo +nightly-2026-09-05 miri setup + + # Miri runs only backend-neutral ownership and bounded queue tests. + # Socket I/O, dynamic FFI, native callbacks, and model loading stay in + # their native CI targets because the interpreter cannot execute them. + - name: Check pure STT worker ownership and queues + run: cargo +nightly-2026-09-05 miri test -p gateway-stt-engine --features test-fixtures miri_ + + # Filters cover generation admission, explicit request and job counts, + # rollback epochs, registry and committed-item ownership, bounded + # results, final segments, and audio state. Spawned tasks stay native. + - name: Check pure STT generation and session ownership + run: cargo +nightly-2026-09-05 miri test -p gateway-stt --features test-fixtures miri_ + + native-whisper: + runs-on: [self-hosted, windows, cuda] + timeout-minutes: 90 + env: + RUSTUP_TOOLCHAIN: 1.89 + RUSTUP_AUTO_INSTALL: "0" + steps: + - uses: actions/checkout@v4 + + - name: Verify preinstalled MSRV Rust + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + $requiredVersion = '1.89.0' + $contractName = 'PROMPTFORGE_RUST_1_89_0_BIN' + $contractBin = [Environment]::GetEnvironmentVariable($contractName) + $rustBin = $null + $rustBinSource = $null + + if (-not [string]::IsNullOrWhiteSpace($contractBin)) { + $contractBin = $contractBin.Trim() + if (-not [IO.Path]::IsPathRooted($contractBin)) { + throw "self-hosted runner Rust $requiredVersion contract $contractName must be an absolute directory: '$contractBin'" + } + if (-not (Test-Path $contractBin -PathType Container)) { + throw "self-hosted runner Rust $requiredVersion contract $contractName directory does not exist: '$contractBin'" + } + $rustBin = (Resolve-Path $contractBin).Path + $rustBinSource = "contract $contractName" + } else { + $candidateBins = [Collections.Generic.List[object]]::new() + $seenBins = [Collections.Generic.HashSet[string]]::new( + [StringComparer]::OrdinalIgnoreCase + ) + + function Add-RustBinCandidate { + param( + [string] $Bin, + [Parameter(Mandatory = $true)][string] $Source + ) + + if ([string]::IsNullOrWhiteSpace($Bin)) { + return + } + try { + $absoluteBin = [IO.Path]::GetFullPath( + [Environment]::ExpandEnvironmentVariables($Bin.Trim()) + ) + } catch { + Write-Host "Ignoring invalid Rust bin candidate from ${Source}: '$Bin'" + return + } + if ($seenBins.Add($absoluteBin)) { + $candidateBins.Add([PSCustomObject]@{ + Bin = $absoluteBin + Source = $Source + }) + } + } + + $toolchainNames = @( + "$requiredVersion-x86_64-pc-windows-msvc", + "$env:RUSTUP_TOOLCHAIN-x86_64-pc-windows-msvc", + 'stable-x86_64-pc-windows-msvc' + ) | Select-Object -Unique + + function Add-RustupToolchainCandidates { + param( + [string] $RustupHome, + [Parameter(Mandatory = $true)][string] $Source + ) + + if ([string]::IsNullOrWhiteSpace($RustupHome)) { + return + } + foreach ($toolchainName in $toolchainNames) { + Add-RustBinCandidate ` + -Bin (Join-Path $RustupHome "toolchains\$toolchainName\bin") ` + -Source "$Source rustup toolchain $toolchainName" + } + } + + $pathCargo = Get-Command 'cargo.exe' -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($pathCargo) { + Add-RustBinCandidate -Bin (Split-Path -Parent $pathCargo.Source) -Source 'PATH cargo.exe' + } + $pathRustc = Get-Command 'rustc.exe' -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($pathRustc) { + Add-RustBinCandidate -Bin (Split-Path -Parent $pathRustc.Source) -Source 'PATH rustc.exe' + } + + $rustupHome = [Environment]::GetEnvironmentVariable('RUSTUP_HOME') + Add-RustupToolchainCandidates -RustupHome $rustupHome -Source 'RUSTUP_HOME' + + $userProfile = [Environment]::GetEnvironmentVariable('USERPROFILE') + if (-not [string]::IsNullOrWhiteSpace($userProfile)) { + Add-RustupToolchainCandidates ` + -RustupHome (Join-Path $userProfile '.rustup') ` + -Source 'USERPROFILE' + } + + # The persistent CUDA runner executes as NetworkService. Rustup's + # prior installer output identified this existing service profile. + $windowsDirectory = [Environment]::GetEnvironmentVariable('WINDIR') + $networkServiceProfile = $null + if (-not [string]::IsNullOrWhiteSpace($windowsDirectory)) { + $networkServiceProfile = Join-Path $windowsDirectory 'ServiceProfiles\NetworkService' + Add-RustupToolchainCandidates ` + -RustupHome (Join-Path $networkServiceProfile '.rustup') ` + -Source 'NetworkService' + } + + $cargoHome = [Environment]::GetEnvironmentVariable('CARGO_HOME') + if (-not [string]::IsNullOrWhiteSpace($cargoHome)) { + Add-RustBinCandidate -Bin (Join-Path $cargoHome 'bin') -Source 'CARGO_HOME' + } + if (-not [string]::IsNullOrWhiteSpace($userProfile)) { + Add-RustBinCandidate -Bin (Join-Path $userProfile '.cargo\bin') -Source 'USERPROFILE' + } + if (-not [string]::IsNullOrWhiteSpace($networkServiceProfile)) { + Add-RustBinCandidate ` + -Bin (Join-Path $networkServiceProfile '.cargo\bin') ` + -Source 'NetworkService service profile' + } + + $candidateReports = [Collections.Generic.List[string]]::new() + foreach ($candidate in $candidateBins) { + $missingTools = @( + 'cargo.exe', + 'rustc.exe' + ) | Where-Object { + -not (Test-Path (Join-Path $candidate.Bin $_) -PathType Leaf) + } + if ($missingTools.Count -eq 0) { + $rustBin = (Resolve-Path $candidate.Bin).Path + $rustBinSource = $candidate.Source + Write-Host "Discovered preprovisioned Rust bin from ${rustBinSource}: '$rustBin'" + break + } + $candidateReports.Add( + "$($candidate.Source) '$($candidate.Bin)' missing $($missingTools -join ', ')" + ) + } + + if ([string]::IsNullOrWhiteSpace($rustBin)) { + $identity = "$([Environment]::UserDomainName)\$([Environment]::UserName)" + $checked = if ($candidateReports.Count -eq 0) { + '(no candidate directories were available)' + } else { + $candidateReports -join '; ' + } + throw "self-hosted runner Rust $requiredVersion is not provisioned for '$identity': no bounded candidate directory contained cargo.exe and rustc.exe. Checked: $checked. Provision both tools together outside CI or set $contractName to their absolute versioned bin directory" + } + } + + function Resolve-RustTool { + param( + [Parameter(Mandatory = $true)][string] $Name, + [Parameter(Mandatory = $true)][string] $Bin + ) + + $candidate = Join-Path $Bin "$Name.exe" + if (-not (Test-Path $candidate -PathType Leaf)) { + throw "self-hosted runner Rust $requiredVersion selected bin from $rustBinSource is missing $Name.exe at '$candidate'; provision cargo.exe and rustc.exe together outside CI or set $contractName" + } + return (Resolve-Path $candidate).Path + } + + function Assert-RustToolVersion { + param( + [Parameter(Mandatory = $true)][string] $Name, + [Parameter(Mandatory = $true)][string] $ToolPath + ) + + $versionLines = @(& $ToolPath '--version' 2>&1) + $exitCode = $LASTEXITCODE + $versionText = ($versionLines | Out-String).Trim() + if ($exitCode -ne 0) { + throw "self-hosted runner $Name.exe failed at '$ToolPath' with exit code ${exitCode}; provision Rust $requiredVersion outside CI and expose it through PATH or $contractName. Output: $versionText" + } + + $versionMatch = [regex]::Match( + $versionText, + "^$([regex]::Escape($Name))\s+(\d+\.\d+\.\d+)(?:\s|$)" + ) + if (-not $versionMatch.Success) { + throw "self-hosted runner $Name.exe returned an unrecognized version at '$ToolPath'; required repository MSRV is exactly $requiredVersion. Output: $versionText" + } + $actualVersion = $versionMatch.Groups[1].Value + if ($actualVersion -ne $requiredVersion) { + throw "self-hosted runner $Name.exe has Rust toolchain version $actualVersion at '$ToolPath'; required repository MSRV is exactly $requiredVersion. Reprovision it outside CI or point $contractName at the correct versioned bin directory" + } + + Write-Host "Using $Name $actualVersion from $ToolPath" + } + + function Test-RustupProxy { + param( + [Parameter(Mandatory = $true)][string] $ToolPath + ) + + $proxyVersionLines = @(& $ToolPath "+$env:RUSTUP_TOOLCHAIN" '--version' 2>&1) + $proxyExitCode = $LASTEXITCODE + if ($proxyExitCode -ne 0) { + return $false + } + $proxyVersionText = ($proxyVersionLines | Out-String).Trim() + return [regex]::IsMatch( + $proxyVersionText, + "^cargo\s+$([regex]::Escape($requiredVersion))(?:\s|$)" + ) + } + + $cargo = Resolve-RustTool -Name 'cargo' -Bin $rustBin + $rustc = Resolve-RustTool -Name 'rustc' -Bin $rustBin + Assert-RustToolVersion -Name 'cargo' -ToolPath $cargo + Assert-RustToolVersion -Name 'rustc' -ToolPath $rustc + + $cargoIsRustupProxy = Test-RustupProxy -ToolPath $cargo + if ($cargoIsRustupProxy) { + $cargoHash = (Get-FileHash $cargo -Algorithm SHA256).Hash + $rustcHash = (Get-FileHash $rustc -Algorithm SHA256).Hash + if ($cargoHash -ne $rustcHash) { + throw "self-hosted runner Rust $requiredVersion mixes a rustup cargo proxy with a direct rustc.exe; provision both tools from one direct or rustup-managed toolchain" + } + $rustup = Resolve-RustTool -Name 'rustup' -Bin $rustBin + $rustupHash = (Get-FileHash $rustup -Algorithm SHA256).Hash + if ($cargoHash -ne $rustupHash) { + throw "self-hosted runner Rust $requiredVersion rustup.exe does not match the selected cargo.exe and rustc.exe proxies" + } + Write-Host "Using rustup proxies from $rustup" + } else { + Write-Host 'Using direct Rust tools' + } + + $rustBin | Add-Content -Path $env:GITHUB_PATH + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + + - name: Provision pinned native fixtures + shell: powershell + run: | + $fixture = Join-Path $env:RUNNER_TEMP 'stt-native' + New-Item -ItemType Directory -Path $fixture -Force | Out-Null + $archive = Join-Path $fixture 'whisper.zip' + Invoke-WebRequest ` + -Uri 'https://github.com/cppalliance/promptforge/releases/download/whisper-lib-b4938/whisper-b4938-windows-x86_64-cuda.zip' ` + -OutFile $archive + $runtimeHash = (Get-FileHash $archive -Algorithm SHA256).Hash + if ($runtimeHash -ne 'F1BC54D7288E21EE826CCB5767249836B780FC316BEC4A0374873E73163DAE12') { + throw "unexpected Whisper runtime hash: $runtimeHash" + } + Expand-Archive -Path $archive -DestinationPath $fixture -Force + $model = Join-Path $fixture 'ggml-tiny.en.bin' + Invoke-WebRequest ` + -Uri 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin' ` + -OutFile $model + $modelHash = (Get-FileHash $model -Algorithm SHA256).Hash + if ($modelHash -ne '921E4CF8686FDD993DCD081A5DA5B6C365BFDE1162E72B08D75AC75289920B1F') { + throw "unexpected Whisper model hash: $modelHash" + } + $audio = Join-Path $fixture 'jfk.wav' + Invoke-WebRequest ` + -Uri 'https://raw.githubusercontent.com/ggml-org/whisper.cpp/b4938/samples/jfk.wav' ` + -OutFile $audio + $audioHash = (Get-FileHash $audio -Algorithm SHA256).Hash + if ($audioHash -ne '59DFB9A4ACB36FE2A2AFFC14BACBEE2920FF435CB13CC314A08C13F66BA7860E') { + throw "unexpected Whisper audio hash: $audioHash" + } + $fixture | Add-Content $env:GITHUB_PATH + "PROMPTFORGE_WHISPER_LIBRARY=$(Join-Path $fixture 'whisper.dll')" | Add-Content $env:GITHUB_ENV + "PROMPTFORGE_WHISPER_MODEL=$model" | Add-Content $env:GITHUB_ENV + "PROMPTFORGE_WHISPER_AUDIO=$audio" | Add-Content $env:GITHUB_ENV + + - name: Test safe Whisper backend integration + run: cargo test --locked -p gateway-stt-backend-whisper --test native_whisper -- --ignored --test-threads=1 + + - name: Test native prompt budgets + run: 'cargo test --locked -p gateway-stt-backend-whisper --lib prompt::tests:: -- --ignored --test-threads=1' + + - name: Test native Whisper FFI + run: cargo test --locked -p gateway-whisper-ffi --lib -- --ignored --test-threads=1 + + - name: Test native Gateway STT units + run: cargo test --locked -p gateway-stt --lib -- --ignored --test-threads=1 + + - name: Test native Gateway STT integration + run: cargo test --locked -p gateway-stt --test it -- --ignored --test-threads=1 diff --git a/.gitignore b/.gitignore index eae913f6..1100be7c 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ /guide/scratch/ *.env # Voice test fixtures, downloaded out of band (see design/design-promptforge-workshop.md). -/crates/gateway-transcribe/tests/fixtures/ +/crates/gateway-stt-backend-whisper/tests/fixtures/ # UI build pipeline: npm install target and the esbuild output. The build # scripts write the bundle to OUT_DIR; `npm run build`/`--watch` still write # dist/ in place for the jsdom tests, and none of it is tracked. @@ -21,3 +21,4 @@ # The gateway sidecar staged for bundle.externalBin by CI before # `tauri build` (crates/workshop/tauri.conf.json); a build artifact. /crates/workshop/binaries/ +/plan-dist-manifest.json diff --git a/AGENTS.md b/AGENTS.md index 6133c939..b33c3fb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,5 +17,5 @@ Multi-crate Rust workspace for the PromptForge pipeline runtime, the inference g - Each crate's own AGENTS.md binds its subtree and is read together with this root file; nested files do not restate workspace-wide rules. - The existing test suite stays green and intact during refactors: fix forward; never rewrite a test to make it pass. - After completing work (compiles + tests pass), update README.md if the public surface changed. -- `config-ui` is a default gateway feature and always present in the desktop build. UI bundles are built by crate build scripts into `OUT_DIR` with esbuild; nothing UI-built is checked into the repo, so every build needs Node 22 and one `npm ci` per `ui/` folder. `cargo build` builds the gateway (workspace default member); `cargo build -p workshop` builds the desktop app. +- `config-ui` is a default gateway feature and always present in the desktop build. UI bundles are built by crate build scripts into `OUT_DIR` with esbuild; nothing UI-built is checked into the repo, so a build needs Node 22 and one `npm ci` for each UI crate it includes. `cargo build` builds the gateway (workspace default member); `cargo build -p workshop` builds the desktop app. - Verify: Rust with `cargo test` at the workspace root (covers the gateway default member; CI runs the full workspace); UI with `npm run typecheck && npm test` in `crates/workshop-server/ui`; config UI with `npm run typecheck && npm run build && npm test` in `crates/gateway-config-ui/ui` (tests import built `dist/app.js`, so build first). diff --git a/Cargo.lock b/Cargo.lock index 1b04c532..74c8f4db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,6 +85,15 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -1873,6 +1882,7 @@ name = "gateway" version = "0.2.0" dependencies = [ "axum", + "base64 0.22.1", "block2", "dotenvy", "embed-resource", @@ -1880,9 +1890,12 @@ dependencies = [ "gateway-config", "gateway-config-ui", "gateway-local", + "gateway-logging", "gateway-routing", "gateway-stt", + "gateway-stt-engine", "gateway-web-search", + "hound", "ksni", "nvml-wrapper", "objc2", @@ -1907,6 +1920,7 @@ dependencies = [ "thiserror 2.0.19", "time", "tokio", + "tokio-tungstenite", "tokio-util", "toml 0.8.2", "tower", @@ -1972,6 +1986,14 @@ dependencies = [ "zip 8.6.0", ] +[[package]] +name = "gateway-logging" +version = "0.2.0" +dependencies = [ + "tracing", + "tracing-subscriber", +] + [[package]] name = "gateway-routing" version = "0.2.0" @@ -1987,11 +2009,13 @@ name = "gateway-stt" version = "0.2.0" dependencies = [ "axum", + "base64 0.22.1", "futures-util", "gateway-config", "gateway-local", "gateway-stt", - "gateway-transcribe", + "gateway-stt-backend-whisper", + "gateway-stt-engine", "hound", "serde", "serde_json", @@ -2001,25 +2025,33 @@ dependencies = [ "thiserror 2.0.19", "tokio", "tokio-tungstenite", + "toml 0.8.2", "tower", "tracing", - "workshop-server", ] [[package]] -name = "gateway-transcribe" +name = "gateway-stt-backend-whisper" version = "0.2.0" dependencies = [ - "gateway-transcribe", + "gateway-stt-engine", "gateway-whisper-ffi", "hound", "shared-progress", "tempfile", - "thiserror 2.0.19", "tokio", "tracing", ] +[[package]] +name = "gateway-stt-engine" +version = "0.2.0" +dependencies = [ + "tempfile", + "thiserror 2.0.19", + "tokio", +] + [[package]] name = "gateway-web-search" version = "0.2.0" @@ -8286,6 +8318,7 @@ name = "workshop-server" version = "0.2.0" dependencies = [ "anyhow", + "arc-swap", "async-trait", "axum", "build-ui", @@ -8303,6 +8336,7 @@ dependencies = [ "rust-embed", "serde", "serde_json", + "shared-loopback", "shared-progress", "shared-sidecar", "socket2", diff --git a/Cargo.toml b/Cargo.toml index 5cd7b6d0..414dfdf1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ license = "BSL-1.0" repository = "https://github.com/cppalliance/promptforge" [workspace.dependencies] +base64 = "0.22" promptforge = { path = "crates/promptforge", version = "0.2.0" } promptforge-core = { path = "crates/promptforge-core", version = "0.2.0" } promptforge-core-support = { path = "crates/promptforge-core-support", version = "0.2.0" } @@ -25,6 +26,7 @@ gateway = { path = "crates/gateway", version = "0.2.0" } gateway-config = { path = "crates/gateway-config", version = "0.2.0" } gateway-config-ui = { path = "crates/gateway-config-ui", version = "0.2.0" } gateway-local = { path = "crates/gateway-local", version = "0.2.0" } +gateway-logging = { path = "crates/gateway-logging", version = "0.2.0" } shared-loopback = { path = "crates/shared-loopback", version = "0.2.0" } shared-protocol = { path = "crates/shared-protocol", version = "0.2.0" } shared-sidecar = { path = "crates/shared-sidecar", version = "0.2.0" } @@ -38,7 +40,8 @@ promptforge-store = { path = "crates/promptforge-store", version = "0.2.0" } promptforge-webfetch = { path = "crates/promptforge-webfetch", version = "0.2.0" } promptforge-tool-picker = { path = "crates/promptforge-tool-picker", version = "0.2.0" } promptforge-tools = { path = "crates/promptforge-tools", version = "0.2.0" } -gateway-transcribe = { path = "crates/gateway-transcribe", version = "0.2.0" } +gateway-stt-engine = { path = "crates/gateway-stt-engine", version = "0.2.0" } +gateway-stt-backend-whisper = { path = "crates/gateway-stt-backend-whisper", version = "0.2.0" } promptforge-web-search = { path = "crates/promptforge-web-search", version = "0.2.0" } gateway-web-search = { path = "crates/gateway-web-search", version = "0.2.0" } workshop-server = { path = "crates/workshop-server", version = "0.2.0" } diff --git a/README.md b/README.md index a24e0c00..83f4b0a7 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ These links always point at the latest tested release. Running a headless gatewa The Workshop is the desktop application. It edits prompts as visible stacks of blocks, runs them against your gateway, and records every run, edit, and decision in an append-only event log. The app updates itself from the release channel. -The gateway is the one process that holds your credentials. It serves an OpenAI-compatible API, routes chat completions to frontier APIs or to local models on your own hardware, and keeps vendor keys off every other process. One configuration file defines the model catalog, the concurrency pools, and the search tool. +The gateway is the one process that holds your credentials. It serves an OpenAI-compatible API, routes chat completions to frontier APIs or to local models on your own hardware, and keeps vendor keys off every other process. One configuration file defines the model catalog, the concurrency pools, and the search tool. When STT is enabled, operational status reports generic configured, ready, GPU, and generation facts, while the model catalog advertises only active speech models. The two ship as separate programs that talk over HTTP: `promptforge-gateway` (the server) and `promptforge-workshop` (the desktop window, which hosts its own server in-process). The installer offers three independent components: **Gateway**, **Workshop**, and **STT** (speech-to-text; a configuration gate, since the runtime and models download on demand). A Gateway-only install is the headless server; a Workshop-only install is a client that attaches to a gateway over the network. With both installed, launching the Workshop attaches to the running gateway or starts one, and closing the window leaves the gateway - and its loaded models - running in the system tray. The tray menu carries **Workshop** (reopens the window), **Settings** (opens the configuration UI in your browser), and **Quit**; the window's own quit command (Quit PromptForge and Gateway) stops both at once. @@ -113,6 +113,12 @@ Rust 1.89 or later. Build, format, and test before you open a PR. CI runs `cargo fmt --check`, `clippy -D warnings`, and `cargo test --workspace`. +To enable automatic local pre-commit and pre-push validation hooks: + +```bash +git config core.hooksPath .githooks +``` + ![Creator](images/promptforge-portrait.png) ## License diff --git a/crates/build-user-guide/src/main.rs b/crates/build-user-guide/src/main.rs index 163a7bb9..e909108d 100644 --- a/crates/build-user-guide/src/main.rs +++ b/crates/build-user-guide/src/main.rs @@ -62,6 +62,7 @@ fn assemble(guide: &Path) -> Result<(), AssembleError> { intro.display() ))); } + check_removed_workshop_stt_claims(&src)?; let mut parts: Vec<(&str, &str, Vec)> = Vec::new(); for (set, part_title) in SETS { @@ -82,6 +83,29 @@ fn assemble(guide: &Path) -> Result<(), AssembleError> { Ok(()) } +/// Reject 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); + for chapter in read_chapters(&set_dir)? { + let path = set_dir.join(chapter.file_name); + let content = fs::read_to_string(&path) + .map_err(|e| AssembleError(format!("cannot read {}: {e}", path.display())))?; + for (index, line) in content.lines().enumerate() { + if line.contains("[workshop.stt]") && !line.to_ascii_lowercase().contains("reject") + { + return Err(AssembleError(format!( + "removed [workshop.stt] section is not described as rejected in {}:{}", + path.display(), + index + 1 + ))); + } + } + } + } + Ok(()) +} + /// List 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> { @@ -303,6 +327,23 @@ mod tests { assert!(error.to_string().contains("workshop/99-gone.md")); } + #[test] + fn assembly_rejects_legacy_workshop_stt_acceptance_claims() { + let dir = fake_guide(); + let chapter = dir.path().join("src").join("gateway").join("01-start.md"); + fs::write( + chapter, + "# Start\n\nLegacy `[workshop.stt]` input is accepted.\n", + ) + .expect("stale chapter"); + let error = assemble(dir.path()).expect_err("must reject stale claim"); + assert!( + error + .to_string() + .contains("removed [workshop.stt] section is not described as rejected") + ); + } + #[test] fn assembly_is_deterministic() { let dir = fake_guide(); diff --git a/crates/gateway-config-ui/ui/src/services/config-store.test.mjs b/crates/gateway-config-ui/ui/src/services/config-store.test.mjs new file mode 100644 index 00000000..1741776b --- /dev/null +++ b/crates/gateway-config-ui/ui/src/services/config-store.test.mjs @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +test("the config store contains no legacy STT canonicalization", async () => { + const source = await readFile(new URL("./config-store.ts", import.meta.url), "utf8"); + + assert.doesNotMatch(source, /\bcanonicalizeStt\b/); + assert.doesNotMatch(source, /workshop\["stt"\]/); +}); diff --git a/crates/gateway-config-ui/ui/src/views/settings-sections.test.mjs b/crates/gateway-config-ui/ui/src/views/settings-sections.test.mjs index 77f3ef5e..6ab7780e 100644 --- a/crates/gateway-config-ui/ui/src/views/settings-sections.test.mjs +++ b/crates/gateway-config-ui/ui/src/views/settings-sections.test.mjs @@ -1,7 +1,7 @@ // Pins the Settings view's editable panels: the Gateway card's // single-config save (untouched secrets ride through as "***", a typed // key leaves the DOM after the save, the restart and new-key notes), -// the Workshop Enable flow with the STT subsection, dominion cards +// the Workshop Enable flow with hot-applied STT tuning, dominion cards // (kind-dependent vram_gb, used-by chips, dependent-naming delete, the // focused draft), endpoint cards (Change-reveal secret, remote-only // dominion options), the Storage save, the Tools Enable flow, the @@ -168,8 +168,14 @@ test("a saved new api_key leaves the DOM and the masked readout returns", async ); }); -test("Workshop exposes STT capture tuning without legacy model paths", async () => { - const stub = fixtureStub(); +test("Workshop exposes canonical STT tuning without legacy model paths", async () => { + const stub = fixtureStub({ + applyOutcome: { + applied: ["gateway.toml"], + reloaded: true, + restart_required: false, + }, + }); const { dom, root } = await bootApp({ key: "k", stub }); navigate(dom, "#/settings/workshop"); @@ -190,29 +196,69 @@ test("Workshop exposes STT capture tuning without legacy model paths", async () assert.match(root.querySelector(".workshop-stt").textContent, /STT capture tuning/); assert.equal( - root.querySelector(".field-row[data-key='stt.window_seconds'] input").value, + root.querySelector(".field-row[data-key='window_seconds'] input").value, "15", "the STT capture defaults mirror the config crate", ); - assert.ok(root.querySelector(".field-row[data-key='stt.vocabulary'] .chip-input, .field-row[data-key='stt.vocabulary'] input")); + assert.ok(root.querySelector(".field-row[data-key='vocabulary'] .chip-input, .field-row[data-key='vocabulary'] input")); assert.equal(root.querySelector("[data-key='stt.interim_model']"), null); assert.equal(root.querySelector("[data-key='stt.final_source']"), null); + assert.equal( + root.querySelector(".restart-note"), + null, + "speech tuning does not claim a gateway restart is required", + ); root.querySelector(".card-save").click(); await settle(); const bodies = putBodies(stub, "/admin/config"); assert.equal(bodies.length, 1); assert.equal( - bodies[0].workshop.bind, + bodies[0].workshop?.bind, undefined, "a fresh section carries no inert hosting bind", ); - assert.equal(bodies[0].workshop.stt.window_seconds, 15); + assert.equal(bodies[0].stt.window_seconds, 15); + assert.equal(bodies[0].workshop?.stt, undefined, "the UI never writes legacy workshop.stt"); assert.equal( bodies[0].server.bind, "127.0.0.1:8081", "a Workshop save still carries the global [server] section", ); + + root.querySelector(".apply-button").click(); + await settle(); + assert.ok( + stub.calls.some((call) => call.url.endsWith("/admin/config-apply")), + "Apply sends the staged STT configuration through the live reload path", + ); + assert.ok( + root.querySelector(".banner-restart").hidden, + "a reloaded STT apply does not ask the operator to restart", + ); +}); + +test("a canonical STT payload round-trips through the Workshop editor", async () => { + const config = modelsFixture(); + config.stt = { window_seconds: 8, interval_ms: 250, vocabulary: ["WG21"] }; + const stub = fixtureStub({ config }); + const { dom, root } = await bootApp({ key: "k", stub }); + + navigate(dom, "#/settings/workshop"); + await settle(); + assert.equal( + root.querySelector(".field-row[data-key='window_seconds'] input").value, + "8", + "the editor reads the canonical top-level section", + ); + changeValue(dom, root.querySelector(".field-row[data-key='window_seconds'] input"), "9"); + await settle(); + root.querySelector(".card-save").click(); + await settle(); + + const body = putBodies(stub, "/admin/config")[0]; + assert.equal(body.stt.window_seconds, 9); + assert.equal(body.workshop, undefined); }); test("a local dominion shows vram_gb, and switching kind to remote hides it", async () => { @@ -533,13 +579,13 @@ test("blurring a chip input commits the pending text as a chip", async () => { root.querySelector(".workshop-enable").click(); await settle(); - const chipInput = root.querySelector(".field-row[data-key='stt.vocabulary'] .chip-input input"); + const chipInput = root.querySelector(".field-row[data-key='vocabulary'] .chip-input input"); assert.ok(chipInput, "the vocabulary chip input renders"); chipInput.value = "GGUF"; chipInput.dispatchEvent(new dom.window.Event("blur")); await settle(); - const chips = [...root.querySelectorAll(".field-row[data-key='stt.vocabulary'] .pill")]; + const chips = [...root.querySelectorAll(".field-row[data-key='vocabulary'] .pill")]; assert.ok( chips.some((chip) => chip.textContent.includes("GGUF")), "blurring commits the typed value as a chip", @@ -555,12 +601,12 @@ test("blurring a chip input with an empty value does not add a chip", async () = root.querySelector(".workshop-enable").click(); await settle(); - const chipInput = root.querySelector(".field-row[data-key='stt.vocabulary'] .chip-input input"); + const chipInput = root.querySelector(".field-row[data-key='vocabulary'] .chip-input input"); chipInput.value = ""; chipInput.dispatchEvent(new dom.window.Event("blur")); await settle(); - const chips = [...root.querySelectorAll(".field-row[data-key='stt.vocabulary'] .pill")]; + const chips = [...root.querySelectorAll(".field-row[data-key='vocabulary'] .pill")]; assert.equal(chips.length, 0, "blurring an empty input adds no chip"); }); diff --git a/crates/gateway-config-ui/ui/src/views/settings-view.ts b/crates/gateway-config-ui/ui/src/views/settings-view.ts index bbf8d330..5ed83209 100644 --- a/crates/gateway-config-ui/ui/src/views/settings-view.ts +++ b/crates/gateway-config-ui/ui/src/views/settings-view.ts @@ -156,15 +156,7 @@ function configUiUrl(bind: string): string { return `http://${host}:${port}/config/`; } -/** The `[workshop]` draft the Add button seeds: the section's one live - * content is the STT capture tuning - the gateway hosts no workshop - * listener, so `bind` and `open_browser` are inert and stay out of the - * editor (existing configs keep them through the save round-trip). */ -function workshopDefaults(): EntryData { - return { stt: sttDefaults() }; -} - -/** The `[workshop.stt]` capture defaults, mirroring the config crate. */ +/** The canonical `[stt]` pipeline defaults, mirroring the config crate. */ function sttDefaults(): EntryData { return { window_seconds: 15, @@ -206,7 +198,7 @@ export function createSettingsView(deps: SettingsViewDeps): SettingsView { let section: SectionId = "system"; /** Unsaved edits: card key -> field path -> value. */ const edits = new Map>(); - /** Browser-created section drafts (`workshop`, `tools`). */ + /** Browser-created section drafts (`stt`, `tools`). */ const sectionDrafts = new Map(); /** Browser-created keyed-array drafts, not yet saved. */ const arrayDrafts: Record<"dominion" | "endpoint", EntryData[]> = { @@ -882,7 +874,7 @@ export function createSettingsView(deps: SettingsViewDeps): SettingsView { /** * Saves one global section through the single configuration shadow. */ - async function saveGlobalCard(card: Card, sectionKey: "server" | "workshop"): Promise { + async function saveGlobalCard(card: Card, sectionKey: "server" | "stt"): Promise { const payload = store.buildConfigPayload(); payload[sectionKey] = effective(card); await store.savePayload(payload); @@ -983,20 +975,20 @@ export function createSettingsView(deps: SettingsViewDeps): SettingsView { } function renderWorkshop(panel: HTMLElement): void { - const pending = store.sectionValue("workshop"); - const draft = sectionDrafts.get("workshop"); + const pending = store.sectionValue("stt"); + const draft = sectionDrafts.get("stt"); if ((pending === null || pending === undefined) && !draft) { - const { card, body } = settingsCard("Workshop"); + const { card, body } = settingsCard("Speech"); const empty = document.createElement("p"); empty.className = "view-empty"; empty.textContent = - "The gateway hosts no workshop listener - the desktop application embeds the workshop server itself. The [workshop] section remains only for speech capture tuning."; + "Speech pipeline tuning is optional. Model files and roles remain in the global STT model catalog."; const enable = document.createElement("button"); enable.type = "button"; enable.className = "button button-primary workshop-enable"; enable.textContent = "Add STT capture tuning"; enable.addEventListener("click", () => { - sectionDrafts.set("workshop", workshopDefaults()); + sectionDrafts.set("stt", sttDefaults()); render(); }); body.append(empty, enable); @@ -1004,97 +996,53 @@ export function createSettingsView(deps: SettingsViewDeps): SettingsView { return; } const card: Card = draft - ? { key: "workshop", base: draft, draft: true, pendingFields: new Set() } + ? { key: "stt", base: draft, draft: true, pendingFields: new Set() } : { - key: "workshop", + key: "stt", base: pending as EntryData, draft: false, - pendingFields: bootPendingFields("workshop"), - runningPrefix: "workshop", + pendingFields: bootPendingFields("stt"), + runningPrefix: "stt", }; - const { card: box, body } = settingsCard("Workshop ([workshop])"); + const { card: box, body } = settingsCard("Speech ([stt])"); + const tuning = document.createElement("section"); + tuning.className = "workshop-stt"; + const tuningHeading = document.createElement("h3"); + tuningHeading.className = "section-heading"; + tuningHeading.textContent = "STT capture tuning"; + tuning.append( + tuningHeading, + fieldRow(card, { + path: "window_seconds", + label: "Window seconds", + help: "Seconds of trailing audio each interim pass transcribes.", + type: "input", + numeric: true, + placeholder: "15", + }), + fieldRow(card, { + path: "interval_ms", + label: "Interval (ms)", + help: "Milliseconds between interim passes while a take is recording.", + type: "input", + numeric: true, + placeholder: "500", + }), + fieldRow(card, { + path: "vocabulary", + label: "Vocabulary", + help: "Domain terms whisper is biased toward.", + type: "chips", + }), + ); body.append( - workshopSubsection(card, "stt", "STT capture tuning", sttDefaults, [ - { - path: "stt.window_seconds", - label: "Window seconds", - help: "Seconds of trailing audio each interim pass transcribes.", - type: "input", - numeric: true, - placeholder: "15", - }, - { - path: "stt.interval_ms", - label: "Interval (ms)", - help: "Milliseconds between interim passes while a take is recording.", - type: "input", - numeric: true, - placeholder: "500", - }, - { - path: "stt.vocabulary", - label: "Vocabulary", - help: "Domain terms whisper is biased toward.", - type: "chips", - }, - ]), + tuning, restoreRecommendedButton(), - restartNote(), - saveButton(card, () => saveGlobalCard(card, "workshop")), + saveButton(card, () => saveGlobalCard(card, "stt")), ); panel.append(box); } - /** A collapsible `[workshop.stt]` subsection. */ - function workshopSubsection( - card: Card, - key: string, - label: string, - seed: () => EntryData, - fields: FieldSpec[], - ): HTMLElement { - const wrap = document.createElement("section"); - wrap.className = `workshop-sub workshop-${key}`; - if (value(card, key) == null) { - const add = document.createElement("button"); - add.type = "button"; - add.className = `button button-outline section-add add-${key}`; - add.textContent = `Add ${label.toLowerCase()} settings`; - add.addEventListener("click", () => { - expanded.add(`workshop:${key}`); - commit(card, key, seed()); - }); - wrap.append(add); - return wrap; - } - const heading = document.createElement("h3"); - heading.className = "section-heading"; - const toggle = document.createElement("button"); - toggle.type = "button"; - toggle.className = "section-toggle"; - const collapseKey = `workshop:${key}`; - toggle.setAttribute("aria-expanded", String(expanded.has(collapseKey))); - toggle.textContent = label; - heading.append(toggle); - const body = document.createElement("div"); - body.className = "section-body"; - body.hidden = !expanded.has(collapseKey); - toggle.addEventListener("click", () => { - if (expanded.has(collapseKey)) { - expanded.delete(collapseKey); - } else { - expanded.add(collapseKey); - } - body.hidden = !expanded.has(collapseKey); - toggle.setAttribute("aria-expanded", String(!body.hidden)); - }); - for (const spec of fields) { - body.append(fieldRow(card, spec)); - } - wrap.append(heading, body); - return wrap; - } - function restoreRecommendedButton(): HTMLElement { const wrap = document.createElement("div"); wrap.className = "restore-stt"; diff --git a/crates/gateway-config/README.md b/crates/gateway-config/README.md index 6450c80a..dfd0c3dc 100644 --- a/crates/gateway-config/README.md +++ b/crates/gateway-config/README.md @@ -13,7 +13,7 @@ config-version = 2 bind = "127.0.0.1:8081" api_key = "${PROMPTFORGE_GATEWAY_API_KEY}" -[workshop.stt] +[stt] window_seconds = 15 interval_ms = 500 @@ -33,15 +33,18 @@ Use this canonical section order to minimize merge noise: 1. `config-version` 2. `[server]` -3. `[workshop]`, `[workshop.stt]` -4. `[local]` -5. `[tools]` and child tables -6. `[[dominion]]` -7. `[[endpoint]]` -8. `[[model]]` -9. `[[local_model]]` and companion tables -10. `[[stt_model]]` -11. `[[profile]]` +3. `[stt]` +4. `[workshop]` +5. `[local]` +6. `[tools]` and child tables +7. `[[dominion]]` +8. `[[endpoint]]` +9. `[[model]]` +10. `[[local_model]]` and companion tables +11. `[[stt_model]]` +12. `[[profile]]` + +Version 2 accepts only the canonical top-level `[stt]` section. Legacy `[workshop.stt]` input, including documents that also define `[stt]`, is rejected as an unknown workshop field. `include`, a sibling `profiles/` directory, the top-level `models` allowlist, and `[workshop.voice]` are rejected. Hard-break diagnostics name the file, removed key, source line, and replacement layout. @@ -79,6 +82,8 @@ Loading validates every profile, not only the active one: The built-in `RECOMMENDED_STT_MODELS` pair is `base.en` for interim and `small.en` for final. Both use canonical whisper.cpp URLs and SHA-256 pins from Hugging Face LFS metadata. The ignored live test downloads both artifacts to detect URL or digest drift. +`realtime-transcribe` is reserved for the Gateway's logical Realtime model and cannot be used as a physical `[[stt_model]]` name. The Gateway advertises that logical name only while an interim and final pair is active; physical names remain the batch transcription selectors. + ## Pending edits `save_config_shadow` accepts the pending admin document. It writes global config to `gateway.toml.next` and writes the matching `active_profile` key to `gateway.state.toml.next`. `load_pending_config` reads those shadows with the same selection precedence. No save touches a real file until `promote_shadow` renames the shadow into place, or a caller holding the intended contents commits them with `write_atomic`, the replace-through-rename primitive both shadows and `persist_profile_state` build on. diff --git a/crates/gateway-config/src/config.rs b/crates/gateway-config/src/config.rs index 7715c245..ef5c6664 100644 --- a/crates/gateway-config/src/config.rs +++ b/crates/gateway-config/src/config.rs @@ -22,10 +22,12 @@ pub(crate) use imp::reject_profiles_directory; #[cfg(test)] pub(crate) use interpolate::interpolate; pub(crate) use interpolate::interpolate_value; -pub use stt::{RECOMMENDED_STT_MODELS, RecommendedSttModel, SttModelConfig, SttRole}; -pub use workshop::{WorkshopConfig, WorkshopSttConfig}; +use stt::RawSttPipelineConfig; +pub use stt::{ + RECOMMENDED_STT_MODELS, RecommendedSttModel, SttModelConfig, SttPipelineConfig, SttRole, +}; +pub use workshop::WorkshopConfig; -#[cfg(test)] use crate::error::ConfigError; #[cfg(test)] @@ -173,8 +175,9 @@ pub struct Config { /// Optional built-in tool configuration. Absent when no `[tools]` section /// is present. tools: Option, - /// Optional hosted-workshop configuration. Absent when no `[workshop]` - /// section is present. Boot-only, like `[server]`. + /// Optional canonical speech pipeline tuning. + stt: Option, + /// Deprecated workshop hosting settings retained for boot compatibility. workshop: Option, } @@ -205,15 +208,24 @@ pub(crate) struct RawConfig { #[serde(default)] tools: Option, #[serde(default)] + stt: Option, + #[serde(default)] workshop: Option, } -impl From for Config { - fn from(raw: RawConfig) -> Config { +impl TryFrom for Config { + type Error = ConfigError; + + fn try_from(raw: RawConfig) -> Result { let models = raw.models.clone(); let local_models = raw.local_models.clone(); let stt_models = raw.stt_models.clone(); - Config { + let stt = raw + .stt + .map(SttPipelineConfig::try_from) + .transpose() + .map_err(|message| ConfigError::Validation(message.to_owned()))?; + Ok(Config { version: raw.config_version, server: raw.server, local: raw.local, @@ -228,8 +240,9 @@ impl From for Config { profiles: raw.profiles, active_profile: None, tools: raw.tools, + stt, workshop: raw.workshop, - } + }) } } diff --git a/crates/gateway-config/src/config/accessors.rs b/crates/gateway-config/src/config/accessors.rs index 03aeea9c..c72f67f1 100644 --- a/crates/gateway-config/src/config/accessors.rs +++ b/crates/gateway-config/src/config/accessors.rs @@ -9,8 +9,8 @@ use std::net::SocketAddr; use super::{ Capabilities, Config, DominionConfig, DominionKind, EndpointConfig, LlamaBackend, LocalConfig, LocalModelConfig, ModelConfig, ModelKind, ProfileConfig, Protocol, QueuePolicy, SearchProvider, - Secret, ServerConfig, SttModelConfig, ThinkingMode, ToolDialect, ToolsConfig, WebSearchConfig, - WorkshopConfig, + Secret, ServerConfig, SttModelConfig, SttPipelineConfig, ThinkingMode, ToolDialect, + ToolsConfig, WebSearchConfig, WorkshopConfig, }; impl Config { @@ -318,6 +318,12 @@ impl Config { self.tools.as_ref() } + /// Returns canonical `[stt]` pipeline tuning, or `None` when absent. + #[must_use] + pub fn stt(&self) -> Option<&SttPipelineConfig> { + self.stt.as_ref() + } + /// Returns the `[workshop]` configuration, or `None` when the section is /// absent. /// diff --git a/crates/gateway-config/src/config/imp.rs b/crates/gateway-config/src/config/imp.rs index c53b53ed..b2c200ab 100644 --- a/crates/gateway-config/src/config/imp.rs +++ b/crates/gateway-config/src/config/imp.rs @@ -11,7 +11,7 @@ use std::path::Path; use serde::Deserialize; -use super::{Config, RawConfig, Secret, WebSearchConfig, interpolate_value}; +use super::{Config, RawConfig, RawSttPipelineConfig, Secret, WebSearchConfig, interpolate_value}; use crate::error::ConfigError; use crate::profile::{ProfileName, ProfileSelection, resolve_selection}; @@ -96,6 +96,7 @@ impl Config { stt_models: self.catalog_stt_models.clone(), profiles: self.profiles.clone(), tools: self.tools.clone(), + stt: self.stt.as_ref().map(RawSttPipelineConfig::from), workshop: self.workshop.clone(), } } @@ -229,7 +230,7 @@ impl Config { path: None, source: Box::new(source), })?; - let mut config = Config::from(raw); + let mut config = Config::try_from(raw)?; config.imply_projector_images(); config.validate()?; Ok(config) @@ -320,7 +321,7 @@ fn reject_removed_layout(raw: &str, path: Option<&Path>) -> Result<(), ConfigErr path, line_for_span(raw, value.span()), key, - "use [workshop.stt] tuning and a global [[stt_model]] entry", + "use [stt] tuning and a global [[stt_model]] entry", )); } } @@ -328,7 +329,7 @@ fn reject_removed_layout(raw: &str, path: Option<&Path>) -> Result<(), ConfigErr path, find_voice_header_line(raw).unwrap_or(1), "workshop.voice", - "rename capture tuning to [workshop.stt] and define models as [[stt_model]]", + "move capture tuning to [stt] and define models as [[stt_model]]", )); } diff --git a/crates/gateway-config/src/config/stt.rs b/crates/gateway-config/src/config/stt.rs index f81a45fe..edf86056 100644 --- a/crates/gateway-config/src/config/stt.rs +++ b/crates/gateway-config/src/config/stt.rs @@ -1,6 +1,133 @@ //! Speech-to-text catalog entries and the digest-pinned recommended pair. -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; + +/// Default sliding-window length for interim transcription, in seconds. +const DEFAULT_STT_WINDOW_SECONDS: u64 = 15; + +/// Default interval between interim transcriptions, in milliseconds. +const DEFAULT_STT_INTERVAL_MS: u64 = 500; + +/// The canonical `[stt]` pipeline tuning section. +/// +/// Model sources and roles live in global `[[stt_model]]` catalog entries and +/// profiles enable them through membership. +/// +/// # Examples +/// ``` +/// use gateway_config::Config; +/// +/// let config = Config::from_toml_str( +/// "config-version = 2\n[server]\nbind = \"127.0.0.1:8080\"\napi_key = \"secret\"\n\ +/// [stt]\nwindow_seconds = 8\n", +/// )?; +/// assert_eq!( +/// config.stt().map(|stt| stt.window_seconds()), +/// Some(8) +/// ); +/// # Ok::<(), gateway_config::ConfigError>(()) +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[non_exhaustive] +pub struct SttPipelineConfig { + /// Seconds of trailing audio each interim pass transcribes. + window_seconds: u64, + /// Milliseconds between interim passes while a take is recording. + interval_ms: u64, + /// Domain terms whisper is biased toward. Empty disables biasing. + vocabulary: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub(crate) struct RawSttPipelineConfig { + window_seconds: u64, + interval_ms: u64, + vocabulary: Vec, +} + +impl Default for RawSttPipelineConfig { + fn default() -> Self { + Self { + window_seconds: DEFAULT_STT_WINDOW_SECONDS, + interval_ms: DEFAULT_STT_INTERVAL_MS, + vocabulary: Vec::new(), + } + } +} + +impl Default for SttPipelineConfig { + fn default() -> Self { + Self { + window_seconds: DEFAULT_STT_WINDOW_SECONDS, + interval_ms: DEFAULT_STT_INTERVAL_MS, + vocabulary: Vec::new(), + } + } +} + +impl TryFrom for SttPipelineConfig { + type Error = &'static str; + + fn try_from(raw: RawSttPipelineConfig) -> Result { + if raw.window_seconds == 0 { + return Err("stt.window_seconds must be at least 1"); + } + if raw.interval_ms == 0 { + return Err("stt.interval_ms must be at least 1"); + } + let seconds = + usize::try_from(raw.window_seconds).map_err(|_| "stt.window_seconds is too large")?; + seconds + .checked_mul(16_000) + .ok_or("stt.window_seconds is too large")?; + Ok(Self { + window_seconds: raw.window_seconds, + interval_ms: raw.interval_ms, + vocabulary: raw.vocabulary, + }) + } +} + +impl From<&SttPipelineConfig> for RawSttPipelineConfig { + fn from(config: &SttPipelineConfig) -> Self { + Self { + window_seconds: config.window_seconds, + interval_ms: config.interval_ms, + vocabulary: config.vocabulary.clone(), + } + } +} + +impl<'de> Deserialize<'de> for SttPipelineConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let raw = RawSttPipelineConfig::deserialize(deserializer)?; + Self::try_from(raw).map_err(serde::de::Error::custom) + } +} + +impl SttPipelineConfig { + /// Returns the seconds of trailing audio each interim pass transcribes. + #[must_use] + pub fn window_seconds(&self) -> u64 { + self.window_seconds + } + + /// Returns the milliseconds between interim passes while a take is recording. + #[must_use] + pub fn interval_ms(&self) -> u64 { + self.interval_ms + } + + /// Returns the domain terms whisper is biased toward. + #[must_use] + pub fn vocabulary(&self) -> &[String] { + &self.vocabulary + } +} /// The engine slot a speech-to-text model fills. /// @@ -286,6 +413,34 @@ mod tests { use super::*; + #[test] + fn public_deserialization_rejects_invalid_pipeline_bounds() { + for json in [ + r#"{"window_seconds":0}"#, + r#"{"interval_ms":0}"#, + r#"{"window_seconds":18446744073709551615}"#, + ] { + assert!( + serde_json::from_str::(json).is_err(), + "invalid public STT pipeline input must fail: {json}" + ); + } + + assert!( + toml::from_str::("window_seconds = 0").is_err(), + "format-specific TOML deserialization must use the same validation boundary" + ); + } + + #[test] + fn public_deserialization_applies_valid_defaults() { + let config: SttPipelineConfig = + serde_json::from_str("{}").expect("default STT pipeline is valid"); + assert_eq!(config.window_seconds(), DEFAULT_STT_WINDOW_SECONDS); + assert_eq!(config.interval_ms(), DEFAULT_STT_INTERVAL_MS); + assert!(config.vocabulary().is_empty()); + } + #[test] fn recommended_pair_is_complete_and_digest_pinned() { assert_eq!(RECOMMENDED_STT_MODELS.len(), 2); diff --git a/crates/gateway-config/src/config/tests/schema.rs b/crates/gateway-config/src/config/tests/schema.rs index 96515c0e..0369040a 100644 --- a/crates/gateway-config/src/config/tests/schema.rs +++ b/crates/gateway-config/src/config/tests/schema.rs @@ -74,6 +74,19 @@ fn canonical_example_uses_the_validated_section_layout() { assert_eq!(selected.stt_models().len(), 2); } +#[test] +fn canonical_stt_section_parses_into_the_runtime_shape() { + let config = Config::from_toml_str(&format!( + "{CATALOG}\n[stt]\nwindow_seconds = 8\ninterval_ms = 250\nvocabulary = [\"WG21\"]\n" + )) + .expect("canonical STT section parses"); + + let stt = config.stt().expect("canonical STT settings are present"); + assert_eq!(stt.window_seconds(), 8); + assert_eq!(stt.interval_ms(), 250); + assert_eq!(stt.vocabulary(), ["WG21"]); +} + #[test] fn hard_breaks_name_file_key_line_and_replacement() { for (raw, key, line, replacement) in [ diff --git a/crates/gateway-config/src/config/tests/serialize.rs b/crates/gateway-config/src/config/tests/serialize.rs index 5f5ac585..7d6ef323 100644 --- a/crates/gateway-config/src/config/tests/serialize.rs +++ b/crates/gateway-config/src/config/tests/serialize.rs @@ -104,7 +104,7 @@ strip_tracking = false bind = "127.0.0.1:7999" open_browser = true -[workshop.stt] +[stt] window_seconds = 8 interval_ms = 250 vocabulary = ["MCP", "GGUF"] @@ -156,12 +156,30 @@ fn serialized_shape_uses_the_toml_key_names() { "profile", "tools", "workshop", + "stt", ] { assert!(top.contains_key(key), "missing top-level key `{key}`"); } + assert!( + json["workshop"].get("stt").is_none(), + "serialization must never emit the legacy workshop.stt shape" + ); assert_eq!(json["local_model"][0]["speculative"]["type"], "draft-mtp"); } +#[test] +fn canonical_stt_input_round_trips_as_canonical_stt() { + let config = Config::from_toml_str( + "config-version = 2\n[server]\nbind = \"127.0.0.1:8081\"\napi_key = \"k\"\n\ + [stt]\nwindow_seconds = 8\n", + ) + .expect("canonical STT input parses"); + let json = config.to_json(); + + assert_eq!(json["stt"]["window_seconds"], 8); + assert!(json["workshop"].is_null()); +} + #[test] fn every_secret_field_serializes_as_redacted() { let json = serde_json::to_value(raw(FULL)).expect("serializes"); diff --git a/crates/gateway-config/src/config/tests/validation.rs b/crates/gateway-config/src/config/tests/validation.rs index 377b2156..e654b544 100644 --- a/crates/gateway-config/src/config/tests/validation.rs +++ b/crates/gateway-config/src/config/tests/validation.rs @@ -196,6 +196,60 @@ fn parses_config_without_tools_section() { assert!(config.tools.is_none()); } +#[test] +fn rejects_legacy_stt_section() { + let toml = r#" +config-version = 2 +[server] +bind = "127.0.0.1:8081" +api_key = "t" + +[workshop.stt] +window_seconds = 8 +"#; + assert!(matches!( + Config::parse_toml(toml), + Err(ConfigError::Parse { .. }) + )); +} + +#[test] +fn rejects_canonical_and_legacy_stt_sections_together() { + let toml = r#" +config-version = 2 +[server] +bind = "127.0.0.1:8081" +api_key = "t" + +[stt] +window_seconds = 8 + +[workshop.stt] +interval_ms = 250 +"#; + assert!(matches!( + Config::parse_toml(toml), + Err(ConfigError::Parse { .. }) + )); +} + +#[test] +fn rejects_zero_stt_pipeline_bounds() { + for field in ["window_seconds = 0", "interval_ms = 0"] { + let toml = format!( + "config-version = 2\n[server]\nbind = \"127.0.0.1:8081\"\napi_key = \"t\"\n\ + [stt]\n{field}\n" + ); + assert!( + matches!( + Config::from_toml_str(&toml), + Err(error) if error.kind() == crate::ConfigErrorKind::Validation + ), + "zero STT bound must fail: {field}" + ); + } +} + #[test] fn secret_redacts() { let s = Secret::new("hunter2".to_string()); diff --git a/crates/gateway-config/src/config/workshop.rs b/crates/gateway-config/src/config/workshop.rs index f672dbc3..313e823c 100644 --- a/crates/gateway-config/src/config/workshop.rs +++ b/crates/gateway-config/src/config/workshop.rs @@ -1,5 +1,5 @@ -//! The optional `[workshop]` section: the embedded workshop UI server the -//! gateway can host on a second loopback listener. +//! Deprecated `[workshop]` hosting settings retained so older boot +//! configurations still parse. //! //! There is deliberately no `[workshop.gateway]` sub-table: the hosting //! gateway derives the workshop's client URL from its own @@ -11,20 +11,11 @@ use std::net::SocketAddr; use serde::{Deserialize, Serialize}; -/// Default sliding-window length for interim transcription, in seconds. -/// Mirrors the workshop server's own default. -const DEFAULT_STT_WINDOW_SECONDS: u64 = 15; - -/// Default interval between interim transcriptions, in milliseconds. -/// Mirrors the workshop server's own default. -const DEFAULT_STT_INTERVAL_MS: u64 = 500; - fn default_workshop_bind() -> SocketAddr { SocketAddr::from(([127, 0, 0, 1], 7910)) } -/// The `[workshop]` section: settings for the workshop UI server hosted by -/// the gateway. +/// The deprecated `[workshop]` hosting section. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] #[non_exhaustive] @@ -37,10 +28,6 @@ pub struct WorkshopConfig { /// it is serving. Defaults to false. #[serde(default)] open_browser: bool, - /// Speech-to-text capture settings. Absent when no `[workshop.stt]` - /// section is present. - #[serde(default)] - stt: Option, } impl WorkshopConfig { @@ -90,147 +77,6 @@ impl WorkshopConfig { pub fn open_browser(&self) -> bool { self.open_browser } - - /// Returns the `[workshop.stt]` settings, or `None` when the section - /// is absent. - /// - /// # Examples - /// ``` - /// # use gateway_config::Config; - /// # let toml = r#" - /// # config-version = 2 - /// # [server] - /// # bind = "127.0.0.1:8080" - /// # api_key = "secret" - /// # - /// # [workshop.stt] - /// # window_seconds = 8 - /// # "#; - /// let config = Config::from_toml_str(toml)?; - /// let workshop = config.workshop().expect("workshop section present"); - /// assert!(workshop.stt().is_some()); - /// # Ok::<(), gateway_config::ConfigError>(()) - /// ``` - #[must_use] - pub fn stt(&self) -> Option<&WorkshopSttConfig> { - self.stt.as_ref() - } -} - -/// The `[workshop.stt]` section: speech capture window, cadence, and bias. -/// -/// Model sources and roles live in global `[[stt_model]]` catalog entries and -/// profiles enable them through membership. -/// -/// # Examples -/// ``` -/// use gateway_config::Config; -/// -/// let config = Config::from_toml_str( -/// "config-version = 2\n[server]\nbind = \"127.0.0.1:8080\"\napi_key = \"secret\"\n\ -/// [workshop.stt]\nwindow_seconds = 8\n", -/// )?; -/// assert_eq!( -/// config.workshop().and_then(|workshop| workshop.stt()).map(|stt| stt.window_seconds()), -/// Some(8) -/// ); -/// # Ok::<(), gateway_config::ConfigError>(()) -/// ``` -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(default, deny_unknown_fields)] -#[non_exhaustive] -pub struct WorkshopSttConfig { - /// Seconds of trailing audio each interim pass transcribes. - window_seconds: u64, - /// Milliseconds between interim passes while a take is recording. - interval_ms: u64, - /// Domain terms whisper is biased toward. Empty disables biasing. - vocabulary: Vec, -} - -impl Default for WorkshopSttConfig { - fn default() -> WorkshopSttConfig { - WorkshopSttConfig { - window_seconds: DEFAULT_STT_WINDOW_SECONDS, - interval_ms: DEFAULT_STT_INTERVAL_MS, - vocabulary: Vec::new(), - } - } -} - -impl WorkshopSttConfig { - /// Returns the seconds of trailing audio each interim pass transcribes. - /// - /// # Examples - /// ``` - /// # use gateway_config::Config; - /// # let toml = r#" - /// # config-version = 2 - /// # [server] - /// # bind = "127.0.0.1:8080" - /// # api_key = "secret" - /// # - /// # [workshop.stt] - /// # window_seconds = 8 - /// # "#; - /// let config = Config::from_toml_str(toml)?; - /// let stt = config.workshop().and_then(|w| w.stt()).expect("stt present"); - /// assert_eq!(stt.window_seconds(), 8); - /// # Ok::<(), gateway_config::ConfigError>(()) - /// ``` - #[must_use] - pub fn window_seconds(&self) -> u64 { - self.window_seconds - } - - /// Returns the milliseconds between interim passes while a take is - /// recording. - /// - /// # Examples - /// ``` - /// # use gateway_config::Config; - /// # let toml = r#" - /// # config-version = 2 - /// # [server] - /// # bind = "127.0.0.1:8080" - /// # api_key = "secret" - /// # - /// # [workshop.stt] - /// # interval_ms = 250 - /// # "#; - /// let config = Config::from_toml_str(toml)?; - /// let stt = config.workshop().and_then(|w| w.stt()).expect("stt present"); - /// assert_eq!(stt.interval_ms(), 250); - /// # Ok::<(), gateway_config::ConfigError>(()) - /// ``` - #[must_use] - pub fn interval_ms(&self) -> u64 { - self.interval_ms - } - - /// Returns the domain terms whisper is biased toward. - /// - /// # Examples - /// ``` - /// # use gateway_config::Config; - /// # let toml = r#" - /// # config-version = 2 - /// # [server] - /// # bind = "127.0.0.1:8080" - /// # api_key = "secret" - /// # - /// # [workshop.stt] - /// # vocabulary = ["MCP", "GGUF"] - /// # "#; - /// let config = Config::from_toml_str(toml)?; - /// let stt = config.workshop().and_then(|w| w.stt()).expect("stt present"); - /// assert_eq!(stt.vocabulary(), ["MCP", "GGUF"]); - /// # Ok::<(), gateway_config::ConfigError>(()) - /// ``` - #[must_use] - pub fn vocabulary(&self) -> &[String] { - &self.vocabulary - } } #[cfg(test)] @@ -256,7 +102,6 @@ mod tests { let workshop = config.workshop().expect("workshop section present"); assert_eq!(workshop.bind().to_string(), "127.0.0.1:7910"); assert!(!workshop.open_browser()); - assert!(workshop.stt().is_none()); } #[test] @@ -266,32 +111,11 @@ mod tests { [workshop] bind = "127.0.0.1:7999" open_browser = true - -[workshop.stt] -window_seconds = 8 -interval_ms = 250 -vocabulary = ["MCP", "GGUF"] "#, ); let workshop = config.workshop().expect("workshop section present"); assert_eq!(workshop.bind().to_string(), "127.0.0.1:7999"); assert!(workshop.open_browser()); - let stt = workshop.stt().expect("stt present"); - assert_eq!(stt.window_seconds(), 8); - assert_eq!(stt.interval_ms(), 250); - assert_eq!(stt.vocabulary(), ["MCP", "GGUF"]); - } - - #[test] - fn workshop_stt_defaults_match_capture_defaults() { - let config = parse("[workshop.stt]\n"); - let stt = config - .workshop() - .and_then(WorkshopConfig::stt) - .expect("stt present"); - assert_eq!(stt.window_seconds(), 15); - assert_eq!(stt.interval_ms(), 500); - assert!(stt.vocabulary().is_empty()); } #[test] @@ -338,10 +162,6 @@ vocabulary = ["MCP", "GGUF"] [workshop] bind = "127.0.0.1:7999" open_browser = true - -[workshop.stt] -window_seconds = 8 -vocabulary = ["MCP", "GGUF"] "#, ); let workshop = config.workshop().expect("workshop section present"); diff --git a/crates/gateway-config/src/lib.rs b/crates/gateway-config/src/lib.rs index c21ebb3e..68263a33 100644 --- a/crates/gateway-config/src/lib.rs +++ b/crates/gateway-config/src/lib.rs @@ -57,8 +57,8 @@ pub use crate::config::{ EndpointConfig, LlamaBackend, LocalConfig, LocalModelConfig, ModelConfig, ModelKind, MultimodalProjectorConfig, ProfileConfig, Protocol, QueuePolicy, RECOMMENDED_STT_MODELS, RecommendedSttModel, SearchProvider, Secret, ServerConfig, SpeculationType, SpeculativeConfig, - SttModelConfig, SttRole, ThinkingMode, ToolDialect, ToolsConfig, WebSearchConfig, - WorkshopConfig, WorkshopSttConfig, + SttModelConfig, SttPipelineConfig, SttRole, ThinkingMode, ToolDialect, ToolsConfig, + WebSearchConfig, WorkshopConfig, }; pub use crate::profile::{ ProfileName, ProfileNameError, ProfileSelection, ProfileState, profile_state_path, diff --git a/crates/gateway-local/src/artifacts/confine.rs b/crates/gateway-local/src/artifacts/confine.rs index 4acdf3a9..19e31ba1 100644 --- a/crates/gateway-local/src/artifacts/confine.rs +++ b/crates/gateway-local/src/artifacts/confine.rs @@ -18,8 +18,8 @@ //! party able to write inside the root, a local actor able to race directory //! creation there already holds the operator's privileges, so the confinement's //! job is to stop malicious *names*, not to defend a shared-tenant cache. On -//! Windows the equivalent restriction is the per-user profile ACL that the -//! default `%USERPROFILE%\.promptforge` inherits. +//! Windows the equivalent restriction is a DACL granted only to the current +//! process token's SID. use std::fs::{self, File}; use std::io::{self, Write}; @@ -33,7 +33,7 @@ use crate::error::LocalError; /// This is a real, verified restriction on every platform (ART-006), never a /// silent no-op: /// - Unix: `chmod 0700`, then verify no group/world mode bits remain. -/// - Windows: strip inherited ACEs and grant the current account full control +/// - Windows: strip inherited ACEs and grant the current process SID full control /// (`icacls /inheritance:r /grant:r`), then verify no broad principal /// (Everyone / Authenticated Users / Users) still appears in the DACL. /// @@ -87,42 +87,142 @@ const BROAD_WINDOWS_PRINCIPALS: [&str; 5] = [ #[cfg(windows)] pub(crate) fn enforce_private_cache_root(root: &Path) -> Result<()> { - let account = current_windows_account(root)?; - set_owner_only_windows_dacl(root, &account)?; + let sid = current_windows_sid(root)?; + set_owner_only_windows_dacl(root, &sid)?; verify_private_windows_dacl(root) } -/// The `DOMAIN\user` (or bare `user`) icacls principal for the current process. +/// Resolves the current process token's SID through the standard Windows CLI. #[cfg(windows)] -fn current_windows_account(root: &Path) -> Result { - let Some(user) = std::env::var("USERNAME") - .ok() - .filter(|value| !value.trim().is_empty()) - else { - return Err(LocalError::CacheNotPrivate { +fn current_windows_sid(root: &Path) -> Result { + let mut cmd = std::process::Command::new("whoami"); + cmd.args(["/user", "/fo", "csv", "/nh"]); + { + use std::os::windows::process::CommandExt; + cmd.creation_flags(crate::CREATE_NO_WINDOW); + } + let output = cmd.output().map_err(|source| LocalError::Io { + operation: "run whoami to resolve cache owner SID", + path: root.to_owned(), + source, + })?; + parse_whoami_user_sid(output.status.success(), &output.stdout, &output.stderr).map_err( + |reason| LocalError::CacheNotPrivate { path: root.to_owned(), - reason: "USERNAME is not set, cannot restrict the cache DACL".to_owned(), + reason, + }, + ) +} + +/// Parses one `whoami /user /fo csv /nh` record into its canonical SID. +#[cfg(any(windows, test))] +pub(super) fn parse_whoami_user_sid( + command_succeeded: bool, + stdout: &[u8], + stderr: &[u8], +) -> std::result::Result { + if !command_succeeded { + let detail = String::from_utf8_lossy(stderr); + let detail = detail.trim(); + return Err(if detail.is_empty() { + "whoami identity query failed".to_owned() + } else { + format!("whoami identity query failed: {detail}") }); + } + + let record = stdout + .strip_suffix(b"\r\n") + .or_else(|| stdout.strip_suffix(b"\n")) + .unwrap_or(stdout); + if record.is_empty() { + return Err("whoami identity output is empty".to_owned()); + } + if record.contains(&b'\r') || record.contains(&b'\n') { + return Err("whoami identity output contains multiple records".to_owned()); + } + + let Some(inner) = record + .strip_prefix(b"\"") + .and_then(|value| value.strip_suffix(b"\"")) + else { + return Err("whoami identity output is not quoted CSV".to_owned()); }; - Ok( - match std::env::var("USERDOMAIN") - .ok() - .filter(|value| !value.trim().is_empty()) + let mut separators = inner + .windows(3) + .enumerate() + .filter(|(_, window)| *window == b"\",\""); + let Some((separator, _)) = separators.next() else { + return Err("whoami identity output does not contain two fields".to_owned()); + }; + if separators.next().is_some() { + return Err("whoami identity output contains extra fields".to_owned()); + } + + let account = &inner[..separator]; + let sid_bytes = &inner[separator + 3..]; + if !account.iter().any(|byte| !byte.is_ascii_whitespace()) || account.contains(&b'"') { + return Err("whoami identity output has an invalid account".to_owned()); + } + let sid = std::str::from_utf8(sid_bytes) + .map_err(|_| "whoami identity output has a non-UTF-8 SID".to_owned())?; + if !is_canonical_windows_sid(sid) { + return Err("whoami identity output has a non-canonical SID".to_owned()); + } + Ok(sid.to_owned()) +} + +#[cfg(any(windows, test))] +fn is_canonical_windows_sid(sid: &str) -> bool { + fn canonical_decimal(value: &str) -> bool { + !value.is_empty() + && value.bytes().all(|byte| byte.is_ascii_digit()) + && (value == "0" || !value.starts_with('0')) + } + + let mut components = sid.split('-'); + if components.next() != Some("S") || components.next() != Some("1") { + return false; + } + let Some(authority) = components.next() else { + return false; + }; + if !canonical_decimal(authority) + || authority + .parse::() + .map_or(true, |value| value > 0xFFFF_FFFF_FFFF) + { + return false; + } + + let mut subauthority_count = 0; + for subauthority in components { + subauthority_count += 1; + if subauthority_count > 15 + || !canonical_decimal(subauthority) + || subauthority.parse::().is_err() { - Some(domain) => format!("{domain}\\{user}"), - None => user, - }, - ) + return false; + } + } + subauthority_count != 0 +} + +/// Renders a validated SID as an `icacls /grant:r` access specification. +#[cfg(any(windows, test))] +#[must_use] +pub(super) fn windows_sid_grant(sid: &str) -> String { + format!("*{sid}:(OI)(CI)F") } -/// Removes inherited ACEs and grants the current account sole full control. +/// Removes inherited ACEs and grants the current process SID sole full control. #[cfg(windows)] -fn set_owner_only_windows_dacl(root: &Path, account: &str) -> Result<()> { +fn set_owner_only_windows_dacl(root: &Path, sid: &str) -> Result<()> { let mut cmd = std::process::Command::new("icacls"); cmd.arg(root) .arg("/inheritance:r") .arg("/grant:r") - .arg(format!("{account}:(OI)(CI)F")); + .arg(windows_sid_grant(sid)); #[cfg(windows)] { use std::os::windows::process::CommandExt; diff --git a/crates/gateway-local/src/artifacts/tests.rs b/crates/gateway-local/src/artifacts/tests.rs index 75eed17d..8b5b6112 100644 --- a/crates/gateway-local/src/artifacts/tests.rs +++ b/crates/gateway-local/src/artifacts/tests.rs @@ -470,6 +470,77 @@ fn concurrent_provisioning_of_same_url_is_safe() { assert!(server.requests() >= 1); } +#[test] +fn whoami_user_parser_accepts_an_ordinary_account_sid() { + let sid = super::confine::parse_whoami_user_sid( + true, + br#""DESKTOP-EXAMPLE\alice","S-1-5-21-111111111-222222222-333333333-1001" +"#, + b"", + ) + .expect("ordinary account parses"); + + assert_eq!(sid, "S-1-5-21-111111111-222222222-333333333-1001"); +} + +#[test] +fn whoami_user_parser_accepts_a_well_known_service_sid() { + let sid = super::confine::parse_whoami_user_sid( + true, + b"\"NT AUTHORITY\\NETWORK SERVICE\",\"S-1-5-20\"\r\n", + b"", + ) + .expect("service account parses"); + + assert_eq!(sid, "S-1-5-20"); +} + +#[test] +fn whoami_user_parser_rejects_malformed_or_multiple_csv_records() { + for output in [ + b"DESKTOP-EXAMPLE\\alice,S-1-5-21-1-2-3-1001".as_slice(), + b"\"alice\",\"S-1-5-21-1-2-3-1001\",\"extra\"".as_slice(), + b"\"alice\",\"S-1-5-21-1-2-3-1001\"\r\n\"bob\",\"S-1-5-21-1-2-3-1002\"\r\n".as_slice(), + b"\"\",\"S-1-5-20\"".as_slice(), + b"\"alice\",\"s-1-5-20\"".as_slice(), + b"\"alice\",\"S-1-5-020\"".as_slice(), + b"\"alice\",\"S-1-5\"".as_slice(), + b"\"alice\",\"S-1-5-4294967296\"".as_slice(), + ] { + assert!( + super::confine::parse_whoami_user_sid(true, output, b"").is_err(), + "unexpectedly accepted {output:?}" + ); + } +} + +#[test] +fn whoami_user_parser_rejects_a_missing_sid() { + assert!(super::confine::parse_whoami_user_sid(true, b"\"alice\",\"\"\r\n", b"").is_err()); + assert!(super::confine::parse_whoami_user_sid(true, b"", b"").is_err()); +} + +#[test] +fn whoami_user_parser_rejects_command_failure() { + let error = super::confine::parse_whoami_user_sid( + false, + b"\"alice\",\"S-1-5-21-1-2-3-1001\"\r\n", + b"ERROR: access denied\r\n", + ) + .expect_err("failed whoami must not yield a SID"); + + assert!(error.contains("whoami identity query failed")); + assert!(error.contains("access denied")); +} + +#[test] +fn windows_sid_grant_uses_the_icacls_sid_prefix() { + assert_eq!( + super::confine::windows_sid_grant("S-1-5-20"), + "*S-1-5-20:(OI)(CI)F" + ); +} + #[cfg(windows)] #[test] fn artifact_store_enforces_private_windows_dacl() { @@ -480,6 +551,8 @@ fn artifact_store_enforces_private_windows_dacl() { let root = dir.path().join("cache"); std::fs::create_dir(&root).expect("mkdir"); let _store = ArtifactStore::new(&root).expect("store"); + std::fs::write(root.join("owner-write-probe"), b"private") + .expect("current process retains cache write access"); let output = std::process::Command::new("icacls") .arg(&root) diff --git a/crates/gateway-logging/AGENTS.md b/crates/gateway-logging/AGENTS.md new file mode 100644 index 00000000..4c0de16d --- /dev/null +++ b/crates/gateway-logging/AGENTS.md @@ -0,0 +1,12 @@ +# gateway-logging + +This crate owns the gateway's log pipeline: the bounded priority queue, the `gateway.log` rotation and file sink, the redaction pass, and the single worker thread that drains formatted records to disk. + +- `gateway` is the only workspace consumer. The crate depends only on the standard library, `tracing`, and `tracing-subscriber`; it never reads the home directory, the environment, Gateway configuration, sidecar state, or STT types - the caller passes the state directory in through `LogConfig`. The boundary is pinned by the manifest test in `tests/it/main.rs`, which fails when any other dependency enters `Cargo.toml`. +- The public surface is exactly `LogConfig`, `LogRuntime`, `LogWriter`, and the opaque `LogError`. Queue lanes, records, rotation, sinks, mutexes, condition variables, and worker handles stay private. (`LogEventWriter` is public but `#[doc(hidden)]`: `MakeWriter::Writer` cannot name a private type, and it is not part of the API contract.) +- Global subscriber installation stays in the binary: this crate supplies the `MakeWriter` file layer and never calls `init` or `set_global_default`. +- Queue policy is fixed: 8192 records total, drain batches of 256, one deque per priority under one mutex. On a full queue, evict the oldest Debug, then Trace, then Info; Warn and Error are never evicted, and a producer with no eligible record blocks on the condition variable. Formatting and allocation happen before locking; the worker writes outside the mutex. +- Retention is fixed at five previous runs: startup rotation shifts `gateway.log` to `gateway.log.1`, the chain through `gateway.log.5`, and deletes the sixth. `LogConfig::log_path` and `LogConfig::retained_log_paths` are the single owner of the layout, so the gateway's `diagnostics` report enumerates the same paths the rotation writes. +- Every record crosses `redact::redact_line` at the one enqueue chokepoint (`LogEventWriter::drop`), masking bearer tokens, authorization and cookie header values, and `api_key` assignments before a line reaches the queue. No log record may carry credentials, environment values, request bodies, audio, transcript text, prompts, or full local model paths. +- File-sink failure falls back to synchronous stderr. `LogRuntime::shutdown` closes admission, drains, flushes, and joins - the gateway shuts the logger down last. +- Test seams: `Sink::Null` (the latency test's baseline) and `Sink::is_stderr` (the fallback contract) exist only under `cfg(test)`. `production_logging_stays_within_latency_budget` is `#[ignore]`d and runs only through `cargo test -p gateway-logging --release -- --ignored`. diff --git a/crates/gateway-logging/Cargo.toml b/crates/gateway-logging/Cargo.toml new file mode 100644 index 00000000..ed309549 --- /dev/null +++ b/crates/gateway-logging/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "gateway-logging" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +description = "PromptForge gateway logging: a bounded priority queue, log rotation, and a worker-owned file sink behind tracing-subscriber's MakeWriter" + +[dependencies] +tracing.workspace = true +tracing-subscriber.workspace = true + +[lints] +workspace = true diff --git a/crates/gateway-logging/src/config.rs b/crates/gateway-logging/src/config.rs new file mode 100644 index 00000000..9d5c9c31 --- /dev/null +++ b/crates/gateway-logging/src/config.rs @@ -0,0 +1,161 @@ +//! The configuration input for [`LogRuntime::start`](crate::LogRuntime::start). + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +/// Numbered segments retained beside `gateway.log`: `.1` is newest and +/// `.5` is oldest. Admitting a sixth retained segment prunes `.5`. +pub(crate) const RETAINED_SEGMENTS: usize = 5; + +/// Marks a segment boundary or a retained tail whose earlier bytes were +/// discarded to restore the fixed-size invariant. +pub(crate) const SEGMENT_TRUNCATION_MARKER: &str = " [truncated]\n"; + +/// Every memory, latency, and disk budget for the logging pipeline. +/// +/// Keeping these limits in one immutable value makes later queue, timeout, +/// shutdown, and rotation work consume the same policy without adding +/// configuration before logging is available. +#[derive(Debug, Clone, Copy)] +pub(crate) struct LogLimits { + pub(crate) max_formatted_record_bytes: usize, + pub(crate) max_queued_bytes: usize, + pub(crate) producer_wait: Duration, + pub(crate) shutdown_wait: Duration, + pub(crate) segment_bytes: u64, + pub(crate) aggregate_retained_bytes: u64, +} + +/// The process-wide logging policy. The queue, timeout, shutdown, and +/// rotation paths consume their reserved fields as those bounds are +/// enforced. +pub(crate) const LOG_LIMITS: LogLimits = LogLimits { + max_formatted_record_bytes: 64 * 1024, + max_queued_bytes: 32 * 1024 * 1024, + producer_wait: Duration::from_millis(25), + shutdown_wait: Duration::from_secs(2), + segment_bytes: 16 * 1024 * 1024, + aggregate_retained_bytes: 96 * 1024 * 1024, +}; + +const _: () = { + assert!(LOG_LIMITS.max_formatted_record_bytes <= LOG_LIMITS.max_queued_bytes); + assert!(LOG_LIMITS.producer_wait.as_millis() < LOG_LIMITS.shutdown_wait.as_millis()); + assert!( + LOG_LIMITS.aggregate_retained_bytes + == LOG_LIMITS.segment_bytes * (RETAINED_SEGMENTS as u64 + 1) + ); + assert!( + LOG_LIMITS.segment_bytes + >= LOG_LIMITS.max_formatted_record_bytes as u64 + + SEGMENT_TRUNCATION_MARKER.len() as u64 + ); +}; + +/// The one input logging needs: the gateway state directory that holds +/// `logs/`. +/// +/// The directory is deliberately the only knob: log discovery must work +/// before configuration parses, so the log location is never configurable. +#[derive(Debug, Clone)] +pub struct LogConfig { + state_dir: PathBuf, +} + +impl LogConfig { + /// Builds a config rooted at `state_dir`; the log file lives at + /// `state_dir/logs/gateway.log`. + /// + /// # Examples + /// ``` + /// let config = gateway_logging::LogConfig::new("/tmp/pf-state"); + /// assert_eq!(config.state_dir(), std::path::Path::new("/tmp/pf-state")); + /// ``` + #[must_use] + pub fn new(state_dir: impl Into) -> Self { + Self { + state_dir: state_dir.into(), + } + } + + /// The state directory the log file is rooted under. + /// + /// # Examples + /// ``` + /// let config = gateway_logging::LogConfig::new("/tmp/pf-state"); + /// assert_eq!(config.state_dir(), std::path::Path::new("/tmp/pf-state")); + /// ``` + #[must_use] + pub fn state_dir(&self) -> &Path { + &self.state_dir + } + + /// The log file this run writes: `/logs/gateway.log`. + /// + /// # Examples + /// ``` + /// let config = gateway_logging::LogConfig::new("/tmp/pf-state"); + /// assert_eq!( + /// config.log_path(), + /// std::path::Path::new("/tmp/pf-state").join("logs").join("gateway.log"), + /// ); + /// ``` + #[must_use] + pub fn log_path(&self) -> PathBuf { + self.state_dir.join("logs").join("gateway.log") + } + + /// The retained log segment paths, `gateway.log.1` (newest) through + /// `gateway.log.5` (oldest). Diagnostics enumerates these without + /// starting a runtime, so the log layout has exactly one owner. + /// + /// # Examples + /// ``` + /// let config = gateway_logging::LogConfig::new("/tmp/pf-state"); + /// let retained = config.retained_log_paths(); + /// assert_eq!(retained.len(), 5); + /// assert!(retained[0].ends_with("gateway.log.1")); + /// assert!(retained[4].ends_with("gateway.log.5")); + /// ``` + #[must_use] + pub fn retained_log_paths(&self) -> Vec { + (1..=RETAINED_SEGMENTS) + .map(|segment| { + self.state_dir + .join("logs") + .join(format!("gateway.log.{segment}")) + }) + .collect() + } + + /// Consumes the config into its state directory, so a one-shot caller + /// such as [`LogRuntime::start`](crate::LogRuntime::start) moves + /// instead of cloning. + pub(crate) fn into_state_dir(self) -> PathBuf { + self.state_dir + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_log_layout_is_current_plus_five_numbered_segments() { + let config = LogConfig::new("state"); + assert_eq!( + config.log_path(), + Path::new("state").join("logs").join("gateway.log") + ); + let retained = config.retained_log_paths(); + assert_eq!( + retained, + (1..=5) + .map(|run| Path::new("state") + .join("logs") + .join(format!("gateway.log.{run}"))) + .collect::>(), + "the retained chain is .1 through .5 in rotation order" + ); + } +} diff --git a/crates/gateway-logging/src/error.rs b/crates/gateway-logging/src/error.rs new file mode 100644 index 00000000..b70fed66 --- /dev/null +++ b/crates/gateway-logging/src/error.rs @@ -0,0 +1,112 @@ +//! The opaque error type returned by [`LogRuntime`](crate::LogRuntime). + +use std::fmt; +use std::io; +use std::path::PathBuf; + +/// A failure to start or shut down a [`LogRuntime`](crate::LogRuntime). +/// +/// Opaque on purpose: the sources stay private so the crate's I/O shape can +/// change without a breaking change, and callers classify with +/// [`is_io`](Self::is_io) instead of matching variants. +#[derive(Debug)] +pub struct LogError(Repr); + +#[derive(Debug)] +enum Repr { + /// Creating `logs/`, rotating the previous log, or opening the fresh + /// one failed. + Open { path: PathBuf, source: io::Error }, + /// The worker thread failed to spawn. + Spawn(io::Error), + /// The worker thread panicked instead of joining cleanly. + WorkerPanicked, +} + +impl LogError { + pub(crate) fn open(path: PathBuf, source: io::Error) -> Self { + Self(Repr::Open { path, source }) + } + + pub(crate) fn spawn(source: io::Error) -> Self { + Self(Repr::Spawn(source)) + } + + pub(crate) fn worker_panicked() -> Self { + Self(Repr::WorkerPanicked) + } + + /// Whether the failure came from an operating-system resource (the + /// filesystem or thread spawn) rather than a worker panic. + /// + /// # Examples + /// ```no_run + /// # let config = gateway_logging::LogConfig::new("/tmp/pf-state"); + /// match gateway_logging::LogRuntime::start(config) { + /// Ok(runtime) => drop(runtime), + /// Err(error) if error.is_io() => eprintln!("log file unavailable: {error}"), + /// Err(error) => eprintln!("logging failed: {error}"), + /// } + /// ``` + #[must_use] + pub fn is_io(&self) -> bool { + matches!(self.0, Repr::Open { .. } | Repr::Spawn(_)) + } +} + +impl fmt::Display for LogError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.0 { + Repr::Open { path, .. } => { + write!(f, "could not open the log file {}", path.display()) + } + Repr::Spawn(_) => f.write_str("could not spawn the log worker thread"), + Repr::WorkerPanicked => f.write_str("the log worker thread panicked"), + } + } +} + +impl std::error::Error for LogError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self.0 { + Repr::Open { source, .. } | Repr::Spawn(source) => Some(source), + Repr::WorkerPanicked => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::error::Error as _; + + #[test] + fn is_io_separates_os_failures_from_worker_panics() { + assert!( + LogError::open(PathBuf::from("gateway.log"), io::Error::other("denied")).is_io(), + "a filesystem failure classifies as I/O" + ); + assert!( + LogError::spawn(io::Error::other("no threads")).is_io(), + "a thread-spawn failure classifies as I/O" + ); + assert!( + !LogError::worker_panicked().is_io(), + "a worker panic is not an I/O failure" + ); + } + + #[test] + fn the_source_chain_reaches_the_io_cause() { + let error = LogError::open(PathBuf::from("gateway.log"), io::Error::other("denied")); + assert_eq!( + error.source().map(ToString::to_string).as_deref(), + Some("denied"), + "the wrapped I/O error stays on the chain" + ); + assert!( + LogError::worker_panicked().source().is_none(), + "a worker panic carries no source" + ); + } +} diff --git a/crates/gateway-logging/src/lib.rs b/crates/gateway-logging/src/lib.rs new file mode 100644 index 00000000..8e2884a0 --- /dev/null +++ b/crates/gateway-logging/src/lib.rs @@ -0,0 +1,75 @@ +//! Bounded, prioritized file logging for the PromptForge gateway. +//! +//! [`LogRuntime`] owns one worker thread that drains a bounded priority +//! queue into a rotated `gateway.log`; [`LogWriter`] adapts the queue to +//! `tracing-subscriber`'s field formatter and `MakeWriter` so the binary's +//! fmt layer redacts classified fields before formatting, then enqueues +//! byte-bounded events instead of blocking producer threads on disk. +//! +//! The crate never installs the global subscriber, never reads the +//! environment or the home directory, and never sees Gateway configuration: +//! the caller passes the state directory in through [`LogConfig`] and +//! composes the subscriber itself. + +mod config; +mod error; +mod queue; +mod redact; +mod runtime; +mod worker; +mod writer; + +pub use crate::config::LogConfig; +pub use crate::error::LogError; +pub use crate::runtime::LogRuntime; +pub use crate::writer::LogWriter; +// Forced onto the public surface by E0446: `MakeWriter::Writer` cannot +// name a private type. Hidden and not part of the API contract. +#[doc(hidden)] +pub use crate::writer::LogEventWriter; + +#[cfg(test)] +pub(crate) mod allocation_tracking { + use std::cell::Cell; + + thread_local! { + static ENABLED: Cell = const { Cell::new(false) }; + static MAX_REQUEST: Cell = const { Cell::new(0) }; + } + + pub(crate) fn record(size: usize) { + ENABLED.with(|enabled| { + if enabled.get() { + MAX_REQUEST.with(|maximum| maximum.set(maximum.get().max(size))); + } + }); + } + + pub(crate) struct AllocationTracker { + active: bool, + } + + impl AllocationTracker { + pub(crate) fn start() -> Self { + ENABLED.with(|enabled| { + assert!(!enabled.replace(true), "allocation tracking is not nested"); + }); + MAX_REQUEST.with(|maximum| maximum.set(0)); + Self { active: true } + } + + pub(crate) fn finish(mut self) -> usize { + self.active = false; + ENABLED.with(|enabled| enabled.set(false)); + MAX_REQUEST.with(Cell::get) + } + } + + impl Drop for AllocationTracker { + fn drop(&mut self) { + if self.active { + let _ = ENABLED.try_with(|enabled| enabled.set(false)); + } + } + } +} diff --git a/crates/gateway-logging/src/queue.rs b/crates/gateway-logging/src/queue.rs new file mode 100644 index 00000000..6d5f2563 --- /dev/null +++ b/crates/gateway-logging/src/queue.rs @@ -0,0 +1,1579 @@ +//! The bounded priority queue: one deque per level under one mutex, a fixed +//! total capacity, and eviction rules that protect Warn and Error records. + +use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Condvar, Mutex, MutexGuard, PoisonError, TryLockError}; +use std::time::{Duration, Instant}; + +use crate::config::LOG_LIMITS; + +const ADMISSION_CLOSED: u64 = 1 << 63; +const ACTIVE_PRODUCER_ONE: u64 = 1 << 32; +const ACTIVE_PRODUCERS: u64 = ((1 << 31) - 1) << 32; +const UNDELIVERED_RECORDS: u64 = (1 << 32) - 1; + +/// Total records the queue holds before producers evict or block. +pub(crate) const CAPACITY: usize = 8192; + +/// Records the worker moves to local storage per drain. +pub(crate) const BATCH: usize = 256; + +/// The priority lanes, from most to least protected. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LogPriority { + Error, + Warn, + Info, + Trace, + Debug, +} + +impl LogPriority { + /// Maps a tracing level onto its lane. + pub(crate) fn from_level(level: tracing::Level) -> Self { + if level == tracing::Level::ERROR { + Self::Error + } else if level == tracing::Level::WARN { + Self::Warn + } else if level == tracing::Level::INFO { + Self::Info + } else if level == tracing::Level::DEBUG { + Self::Debug + } else { + Self::Trace + } + } + + /// The deque index: Error is lane 0, Debug lane 4. + fn lane(self) -> usize { + match self { + Self::Error => 0, + Self::Warn => 1, + Self::Info => 2, + Self::Trace => 3, + Self::Debug => 4, + } + } + + /// The lanes an incoming record at this priority may evict from, in + /// eviction order. A record never evicts a more important one: Debug + /// evicts only Debug, Trace adds Trace, and anything at Info or above + /// may evict any of the three lowest lanes. Warn and Error records are + /// never eviction targets. + fn evictable(self) -> &'static [LogPriority] { + match self { + Self::Debug => &[Self::Debug], + Self::Trace => &[Self::Debug, Self::Trace], + Self::Error | Self::Warn | Self::Info => &[Self::Debug, Self::Trace, Self::Info], + } + } +} + +/// One formatted event: its global sequence, its lane, and the owned line. +#[derive(Debug)] +pub(crate) struct LogRecord { + pub(crate) sequence: u64, + pub(crate) priority: LogPriority, + pub(crate) line: Box, +} + +/// What one worker drain produced: the records in global sequence order, +/// the pressure summary once the queue empties after evictions, and whether +/// a closed queue has nothing left. +#[derive(Debug)] +pub(crate) struct Batch { + pub(crate) records: Vec, + pub(crate) summary: Option>, + pub(crate) summary_affected: u64, + pub(crate) done: bool, +} + +/// Whether bounded formatting retained a whole record or a marked prefix. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum FormatStatus { + Complete, + Truncated, +} + +/// The shared queue state producers and the single worker synchronize on. +#[derive(Debug)] +pub(crate) struct LogQueue { + state: Mutex, + work_available: Condvar, + space_available: Condvar, + limits: QueueLimits, + admission_gate: AtomicU64, + abandoned: AtomicBool, + pending_rejections: AtomicU64, + outstanding_summaries: AtomicU64, + unreported_pressure_records: AtomicU64, + shutdown_abandoned_records: AtomicU64, + shutdown_abandoned_summaries: AtomicU64, + shutdown_unreported_pressure_records: AtomicU64, +} + +/// Admission limits and their shared low-water definition. Pressure has +/// recovered only when both dimensions are at or below half capacity. +#[derive(Debug, Clone, Copy)] +struct QueueLimits { + max_records: usize, + max_bytes: usize, + producer_wait: Duration, +} + +impl QueueLimits { + fn is_at_low_water(self, records: usize, bytes: usize) -> bool { + records <= self.max_records / 2 && bytes <= self.max_bytes / 2 + } +} + +#[derive(Debug)] +struct State { + lanes: [VecDeque; 5], + len: usize, + queued_bytes: usize, + next_sequence: u64, + closed: bool, + loss: LossCounts, + pending_summaries: VecDeque, + pending_pressure_records: u64, + in_flight_records: usize, + in_flight_summaries: usize, + in_flight_pressure_records: u64, + blocked_producers: u64, + #[cfg(test)] + peak_queued_bytes: usize, +} + +/// Records and already-built summaries that could not be delivered before +/// the shutdown budget expired. The counters live in queue state from +/// construction, so recording a timeout never needs to allocate. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ShutdownLoss { + pub(crate) abandoned_records: u64, + pub(crate) abandoned_summaries: u64, + pub(crate) unreported_pressure_records: u64, +} + +/// A closed pressure episode sequenced immediately after every record that +/// had already been admitted when occupancy recovered. +#[derive(Debug)] +struct PendingSummary { + after_sequence: u64, + text: Box, + affected: u64, +} + +/// Counts every way record content is lost during one observable pressure +/// episode. +#[derive(Debug, Default)] +struct LossCounts { + evicted: [u64; 5], + truncated: u64, + rejected: u64, +} + +impl LossCounts { + fn is_empty(&self) -> bool { + self.evicted.iter().all(|&count| count == 0) && self.truncated == 0 && self.rejected == 0 + } + + fn evicted(&self) -> u64 { + self.evicted + .iter() + .fold(0u64, |total, count| total.saturating_add(*count)) + } + + fn affected(&self) -> u64 { + self.evicted() + .saturating_add(self.truncated) + .saturating_add(self.rejected) + } + + fn take_summary(&mut self) -> Option<(Box, u64)> { + if self.is_empty() { + return None; + } + let dropped = self.evicted().saturating_add(self.rejected); + let affected = self.affected(); + let summary = format!( + "log pressure affected {affected} record(s): dropped={dropped}, debug={}, trace={}, info={}, truncated={}, rejected={}\n", + self.evicted[LogPriority::Debug.lane()], + self.evicted[LogPriority::Trace.lane()], + self.evicted[LogPriority::Info.lane()], + self.truncated, + self.rejected, + ) + .into_boxed_str(); + *self = Self::default(); + Some((summary, affected)) + } +} + +impl State { + fn can_admit(&self, limits: QueueLimits, line_bytes: usize) -> bool { + self.len < limits.max_records + && line_bytes <= limits.max_bytes.saturating_sub(self.queued_bytes) + } + + fn admit(&mut self, priority: LogPriority, line: Box) { + let line_bytes = line.len(); + let sequence = self.next_sequence; + self.next_sequence = self.next_sequence.wrapping_add(1); + self.lanes[priority.lane()].push_back(LogRecord { + sequence, + priority, + line, + }); + self.len += 1; + self.queued_bytes += line_bytes; + #[cfg(test)] + { + self.peak_queued_bytes = self.peak_queued_bytes.max(self.queued_bytes); + } + } + + /// Evicts the oldest record the incoming priority is allowed to + /// displace. + fn evict_for(&mut self, priority: LogPriority) -> Option { + for &lane_priority in priority.evictable() { + if let Some(record) = self.lanes[lane_priority.lane()].pop_front() { + self.len -= 1; + self.queued_bytes -= record.line.len(); + return Some(record); + } + } + None + } + + fn oldest_lane(&self) -> Option { + let mut oldest: Option = None; + for (index, lane) in self.lanes.iter().enumerate() { + let Some(front) = lane.front() else { + continue; + }; + match oldest { + Some(current) + if front.sequence + >= self.lanes[current] + .front() + .map_or(u64::MAX, |head| head.sequence) => {} + _ => oldest = Some(index), + } + } + oldest + } + + fn oldest_sequence(&self) -> Option { + self.oldest_lane() + .and_then(|index| self.lanes[index].front()) + .map(|record| record.sequence) + } + + /// Pops the lane head with the smallest global sequence, so drained + /// output stays chronological across lanes. + fn pop_oldest(&mut self) -> Option { + let index = self.oldest_lane()?; + let record = self.lanes[index].pop_front(); + if let Some(record) = &record { + self.len -= 1; + self.queued_bytes -= record.line.len(); + } + record + } + + fn close_loss_episode(&mut self) { + let Some((text, affected)) = self.loss.take_summary() else { + return; + }; + self.pending_pressure_records = self.pending_pressure_records.saturating_add(affected); + self.pending_summaries.push_back(PendingSummary { + after_sequence: self.next_sequence, + text, + affected, + }); + } + + fn pending_summary_fence(&self) -> Option { + self.pending_summaries + .front() + .map(|summary| summary.after_sequence) + } + + fn take_ready_summary(&mut self) -> Option { + let fence = self.pending_summary_fence()?; + if self + .oldest_sequence() + .is_some_and(|sequence| sequence < fence) + { + return None; + } + let summary = self.pending_summaries.pop_front()?; + self.pending_pressure_records = self + .pending_pressure_records + .saturating_sub(summary.affected); + Some(summary) + } +} + +impl LogQueue { + pub(crate) fn new() -> Self { + Self::with_limits( + CAPACITY, + LOG_LIMITS.max_queued_bytes, + LOG_LIMITS.producer_wait, + ) + } + + fn with_limits(max_records: usize, max_bytes: usize, producer_wait: Duration) -> Self { + assert!(max_records > 0, "a queue needs record capacity"); + assert!(max_bytes > 0, "a queue needs byte capacity"); + Self { + state: Mutex::new(State { + lanes: std::array::from_fn(|_| VecDeque::new()), + len: 0, + queued_bytes: 0, + next_sequence: 0, + closed: false, + loss: LossCounts::default(), + pending_summaries: VecDeque::with_capacity( + max_records.div_ceil(BATCH).saturating_add(1), + ), + pending_pressure_records: 0, + in_flight_records: 0, + in_flight_summaries: 0, + in_flight_pressure_records: 0, + blocked_producers: 0, + #[cfg(test)] + peak_queued_bytes: 0, + }), + work_available: Condvar::new(), + space_available: Condvar::new(), + limits: QueueLimits { + max_records, + max_bytes, + producer_wait, + }, + admission_gate: AtomicU64::new(0), + abandoned: AtomicBool::new(false), + pending_rejections: AtomicU64::new(0), + outstanding_summaries: AtomicU64::new(0), + unreported_pressure_records: AtomicU64::new(0), + shutdown_abandoned_records: AtomicU64::new(0), + shutdown_abandoned_summaries: AtomicU64::new(0), + shutdown_unreported_pressure_records: AtomicU64::new(0), + } + } + + fn lock_until(&self, deadline: Instant) -> Option> { + loop { + match self.state.try_lock() { + Ok(state) => return Some(state), + Err(TryLockError::Poisoned(error)) => return Some(error.into_inner()), + Err(TryLockError::WouldBlock) => { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return None; + } + std::thread::yield_now(); + } + } + } + } + + fn begin_producer(&self) -> bool { + let mut gate = self.admission_gate.load(Ordering::Acquire); + loop { + if gate & ADMISSION_CLOSED != 0 + || gate & ACTIVE_PRODUCERS == ACTIVE_PRODUCERS + || gate & UNDELIVERED_RECORDS == UNDELIVERED_RECORDS + { + return false; + } + match self.admission_gate.compare_exchange_weak( + gate, + gate + ACTIVE_PRODUCER_ONE + 1, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => return true, + Err(current) => gate = current, + } + } + } + + fn finish_admitted_producer(&self) { + let previous = self + .admission_gate + .fetch_sub(ACTIVE_PRODUCER_ONE, Ordering::AcqRel); + debug_assert!(previous & ACTIVE_PRODUCERS > 0); + } + + fn finish_rejected_producer(&self) { + let previous = self + .admission_gate + .fetch_sub(ACTIVE_PRODUCER_ONE + 1, Ordering::AcqRel); + debug_assert!(previous & ACTIVE_PRODUCERS > 0); + debug_assert!(previous & UNDELIVERED_RECORDS > 0); + } + + fn close_admission(&self) { + self.admission_gate + .fetch_or(ADMISSION_CLOSED, Ordering::AcqRel); + } + + fn active_producers(&self) -> u64 { + (self.admission_gate.load(Ordering::Acquire) & ACTIVE_PRODUCERS) >> 32 + } + + fn outstanding_records(&self) -> u64 { + self.admission_gate.load(Ordering::Acquire) & UNDELIVERED_RECORDS + } + + fn subtract_undelivered(&self, amount: u64) { + let _ = self + .admission_gate + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + let records = current & UNDELIVERED_RECORDS; + Some(current.saturating_sub(records.min(amount))) + }); + } + + fn saturating_sub(counter: &AtomicU64, amount: u64) { + let _ = counter.fetch_update(Ordering::AcqRel, Ordering::Acquire, |current| { + Some(current.saturating_sub(amount)) + }); + } + + fn begin_loss(&self, state: &State) { + if state.loss.is_empty() { + self.outstanding_summaries.fetch_add(1, Ordering::AcqRel); + } + } + + fn record_rejection(&self, state: &mut State) { + self.begin_loss(state); + state.loss.rejected = state.loss.rejected.saturating_add(1); + self.unreported_pressure_records + .fetch_add(1, Ordering::AcqRel); + } + + fn record_rejections(&self, state: &mut State, count: u64) { + if count == 0 { + return; + } + self.begin_loss(state); + state.loss.rejected = state.loss.rejected.saturating_add(count); + self.unreported_pressure_records + .fetch_add(count, Ordering::AcqRel); + } + + fn record_rejection_without_lock(&self) { + if self.pending_rejections.fetch_add(1, Ordering::AcqRel) == 0 { + self.outstanding_summaries.fetch_add(1, Ordering::AcqRel); + } + self.unreported_pressure_records + .fetch_add(1, Ordering::AcqRel); + } + + fn merge_pending_rejections(&self, state: &mut State) { + let pending = self.pending_rejections.swap(0, Ordering::AcqRel); + if pending == 0 { + return; + } + if !state.loss.is_empty() { + Self::saturating_sub(&self.outstanding_summaries, 1); + } + state.loss.rejected = state.loss.rejected.saturating_add(pending); + } + + fn record_truncation(&self, state: &mut State) { + self.begin_loss(state); + state.loss.truncated = state.loss.truncated.saturating_add(1); + self.unreported_pressure_records + .fetch_add(1, Ordering::AcqRel); + } + + fn record_eviction(&self, state: &mut State, record: &LogRecord) { + self.begin_loss(state); + state.loss.evicted[record.priority.lane()] = + state.loss.evicted[record.priority.lane()].saturating_add(1); + self.subtract_undelivered(1); + self.unreported_pressure_records + .fetch_add(1, Ordering::AcqRel); + } + + /// Enqueues `line`, assigning its global sequence atomically with + /// successful admission. Formatting and allocation still happen before + /// the mutex. When either record or byte capacity is exhausted, the + /// oldest eligible lower-priority records are evicted until the line + /// fits; with none eligible the producer blocks on the condition + /// variable until the worker frees space. After admission closes, new + /// records are dropped. + #[cfg(test)] + pub(crate) fn enqueue(&self, priority: LogPriority, line: Box) { + self.enqueue_formatted(priority, line, FormatStatus::Complete); + } + + /// Enqueues one bounded formatter result and accounts marked + /// truncation in the same pressure episode as queue eviction. + pub(crate) fn enqueue_formatted( + &self, + priority: LogPriority, + line: Box, + status: FormatStatus, + ) { + self.enqueue_after(priority, line, status, || {}); + } + + /// Testable preparation boundary: `before_admission` runs after the + /// owned record exists but before admission locks and assigns sequence. + fn enqueue_after( + &self, + priority: LogPriority, + line: Box, + status: FormatStatus, + before_admission: impl FnOnce(), + ) { + self.enqueue_around(priority, line, status, before_admission, || {}); + } + + fn enqueue_around( + &self, + priority: LogPriority, + line: Box, + status: FormatStatus, + before_admission: impl FnOnce(), + after_admission: impl FnOnce(), + ) { + if !self.begin_producer() { + return; + } + let started = Instant::now(); + let deadline = started + .checked_add(self.limits.producer_wait) + .unwrap_or(started); + let line_bytes = line.len(); + before_admission(); + let Some(mut state) = self.lock_until(deadline) else { + if !self.is_abandoned() { + self.record_rejection_without_lock(); + } + self.finish_rejected_producer(); + self.work_available.notify_one(); + return; + }; + self.merge_pending_rejections(&mut state); + let mut close_accounted = false; + loop { + if state.closed { + if close_accounted { + self.finish_admitted_producer(); + } else { + self.record_rejection(&mut state); + self.finish_rejected_producer(); + } + drop(state); + self.work_available.notify_one(); + return; + } + if line_bytes > self.limits.max_bytes { + self.record_rejection(&mut state); + self.finish_rejected_producer(); + drop(state); + self.work_available.notify_one(); + return; + } + if state.can_admit(self.limits, line_bytes) { + if status == FormatStatus::Truncated { + self.record_truncation(&mut state); + } + state.admit(priority, line); + after_admission(); + self.finish_admitted_producer(); + drop(state); + self.work_available.notify_one(); + return; + } + if let Some(evicted) = state.evict_for(priority) { + self.record_eviction(&mut state, &evicted); + continue; + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + self.record_rejection(&mut state); + self.finish_rejected_producer(); + drop(state); + self.work_available.notify_one(); + return; + } + state.blocked_producers = state.blocked_producers.saturating_add(1); + let (next, timeout) = self + .space_available + .wait_timeout(state, remaining) + .unwrap_or_else(PoisonError::into_inner); + state = next; + state.blocked_producers = state.blocked_producers.saturating_sub(1); + close_accounted = state.closed; + if timeout.timed_out() && !state.closed { + self.record_rejection(&mut state); + self.finish_rejected_producer(); + drop(state); + self.work_available.notify_one(); + return; + } + } + } + + /// Rejects invalid formatter bytes and wakes the worker so the loss is + /// observable even when no queue record accompanies it. + pub(crate) fn reject_formatted(&self) { + if !self.begin_producer() { + return; + } + let started = Instant::now(); + let deadline = started + .checked_add(self.limits.producer_wait) + .unwrap_or(started); + let Some(mut state) = self.lock_until(deadline) else { + if !self.is_abandoned() { + self.record_rejection_without_lock(); + } + self.finish_rejected_producer(); + self.work_available.notify_one(); + return; + }; + self.merge_pending_rejections(&mut state); + if state.closed { + self.record_rejection(&mut state); + self.finish_rejected_producer(); + drop(state); + self.work_available.notify_one(); + return; + } + self.record_rejection(&mut state); + self.finish_rejected_producer(); + drop(state); + self.work_available.notify_one(); + } + + /// Blocks until records are available (or the queue is closed and + /// drained), moves up to [`BATCH`] of them out in global sequence + /// order, and attaches the pressure summary once both record and byte + /// occupancy reach their half-capacity low-water marks. Every write + /// happens on the caller's side, outside the mutex. + pub(crate) fn take_batch(&self) -> Batch { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + loop { + self.merge_pending_rejections(&mut state); + if self.is_abandoned() { + return Batch { + records: Vec::new(), + summary: None, + summary_affected: 0, + done: true, + }; + } + if state.len == 0 + && state.loss.is_empty() + && state.pending_summaries.is_empty() + && (!state.closed || self.active_producers() > 0) + { + state = self + .work_available + .wait(state) + .unwrap_or_else(PoisonError::into_inner); + continue; + } + let mut records = Vec::with_capacity(BATCH.min(state.len)); + let pending_fence = state.pending_summary_fence(); + while records.len() < BATCH { + if pending_fence.is_some_and(|fence| { + state + .oldest_sequence() + .is_none_or(|sequence| sequence >= fence) + }) { + break; + } + let Some(record) = state.pop_oldest() else { + break; + }; + records.push(record); + } + if self.limits.is_at_low_water(state.len, state.queued_bytes) { + state.close_loss_episode(); + } + let ready_summary = state.take_ready_summary(); + let summary_affected = ready_summary.as_ref().map_or(0, |summary| summary.affected); + let summary = ready_summary.map(|summary| summary.text); + state.in_flight_records += records.len(); + state.in_flight_summaries += usize::from(summary.is_some()); + state.in_flight_pressure_records = state + .in_flight_pressure_records + .saturating_add(summary_affected); + let done = state.closed + && state.len == 0 + && state.loss.is_empty() + && state.pending_summaries.is_empty() + && self.active_producers() == 0; + drop(state); + self.space_available.notify_all(); + return Batch { + records, + summary, + summary_affected, + done, + }; + } + } + + /// Whether the queue holds no records. Test seam for the writer's + /// drop-to-enqueue contract. + #[cfg(test)] + pub(crate) fn is_empty(&self) -> bool { + self.state + .lock() + .unwrap_or_else(PoisonError::into_inner) + .len + == 0 + } + + #[cfg(test)] + pub(crate) fn new_for_test(max_records: usize, max_bytes: usize) -> Self { + Self::with_limits(max_records, max_bytes, LOG_LIMITS.producer_wait) + } + + #[cfg(test)] + pub(crate) fn new_for_test_with_wait( + max_records: usize, + max_bytes: usize, + producer_wait: Duration, + ) -> Self { + Self::with_limits(max_records, max_bytes, producer_wait) + } + + #[cfg(test)] + pub(crate) fn hold_lock_for_test( + &self, + entered: &std::sync::mpsc::SyncSender<()>, + release: &std::sync::mpsc::Receiver<()>, + ) { + let _state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + entered.send(()).expect("report held queue mutex"); + release.recv().expect("release queue mutex"); + } + + #[cfg(test)] + fn accounting_for_test(&self) -> (usize, usize, usize) { + let state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + (state.len, state.queued_bytes, state.peak_queued_bytes) + } + + #[cfg(test)] + pub(crate) fn shutdown_loss_for_test(&self) -> ShutdownLoss { + ShutdownLoss { + abandoned_records: self.shutdown_abandoned_records.load(Ordering::Acquire), + abandoned_summaries: self.shutdown_abandoned_summaries.load(Ordering::Acquire), + unreported_pressure_records: self + .shutdown_unreported_pressure_records + .load(Ordering::Acquire), + } + } + + /// Marks one flushed batch as delivered. Records remain in flight until + /// flush returns because buffered writes alone are not final delivery. + pub(crate) fn complete_batch(&self, records: usize, had_summary: bool, summary_affected: u64) { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + state.in_flight_records = state.in_flight_records.saturating_sub(records); + state.in_flight_summaries = state + .in_flight_summaries + .saturating_sub(usize::from(had_summary)); + state.in_flight_pressure_records = state + .in_flight_pressure_records + .saturating_sub(summary_affected); + self.subtract_undelivered(u64::try_from(records).unwrap_or(u64::MAX)); + if had_summary { + Self::saturating_sub(&self.outstanding_summaries, 1); + } + Self::saturating_sub(&self.unreported_pressure_records, summary_affected); + } + + /// Whether shutdown has abandoned delivery after its finite wait. + pub(crate) fn is_abandoned(&self) -> bool { + self.abandoned.load(Ordering::Acquire) + } + + /// Accounts everything not known to have reached the sink and prevents a + /// later queue batch from beginning. The bounded queue storage stays with + /// the detached worker rather than making timeout cleanup part of the + /// caller's latency. + pub(crate) fn abandon(&self) -> ShutdownLoss { + self.close_admission(); + self.abandoned.store(true, Ordering::Release); + let loss = ShutdownLoss { + abandoned_records: self.outstanding_records(), + abandoned_summaries: self.outstanding_summaries.swap(0, Ordering::AcqRel), + unreported_pressure_records: self.unreported_pressure_records.swap(0, Ordering::AcqRel), + }; + self.shutdown_abandoned_records + .fetch_add(loss.abandoned_records, Ordering::AcqRel); + self.shutdown_abandoned_summaries + .fetch_add(loss.abandoned_summaries, Ordering::AcqRel); + self.shutdown_unreported_pressure_records + .fetch_add(loss.unreported_pressure_records, Ordering::AcqRel); + self.space_available.notify_all(); + self.work_available.notify_all(); + loss + } + + /// Closes admission and wakes every waiter: producers drop new + /// records, blocked producers return, and the worker exits once the + /// queue drains. + #[cfg(test)] + pub(crate) fn close(&self) { + self.close_admission(); + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + self.close_locked(&mut state); + drop(state); + self.work_available.notify_all(); + self.space_available.notify_all(); + } + + /// Closes admission within an existing shutdown budget. Failure means + /// the mutex owner consumed that budget and the caller must abandon. + pub(crate) fn close_until(&self, deadline: Instant) -> bool { + self.close_admission(); + let Some(mut state) = self.lock_until(deadline) else { + return false; + }; + self.close_locked(&mut state); + drop(state); + self.work_available.notify_all(); + self.space_available.notify_all(); + true + } + + fn close_locked(&self, state: &mut State) { + self.merge_pending_rejections(state); + if state.closed { + return; + } + state.closed = true; + self.record_rejections(state, state.blocked_producers); + self.subtract_undelivered(state.blocked_producers); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::sync::mpsc; + use std::time::Duration; + + fn line(text: &str) -> Box { + Box::from(text) + } + + fn drain_all(queue: &LogQueue) -> Vec { + let mut all = Vec::new(); + loop { + let batch = queue.take_batch(); + all.extend(batch.records); + if batch.done { + return all; + } + } + } + + #[test] + fn a_full_queue_evicts_oldest_debug_then_trace_then_info() { + let queue = LogQueue::new(); + queue.enqueue(LogPriority::Debug, line("debug-oldest")); + queue.enqueue(LogPriority::Trace, line("trace-oldest")); + queue.enqueue(LogPriority::Info, line("info-oldest")); + for index in 0..(CAPACITY - 3) { + queue.enqueue(LogPriority::Warn, line(&format!("warn-{index}"))); + } + // Each Error evicts the oldest record of the least protected + // eligible lane: Debug first, then Trace, then Info. + queue.enqueue(LogPriority::Error, line("error-1")); + queue.enqueue(LogPriority::Error, line("error-2")); + queue.enqueue(LogPriority::Error, line("error-3")); + queue.close(); + + let records = drain_all(&queue); + let lines: Vec<&str> = records.iter().map(|record| &*record.line).collect(); + assert!( + !lines.contains(&"debug-oldest"), + "the oldest Debug is the first eviction victim" + ); + assert!( + !lines.contains(&"trace-oldest"), + "with the Debug lane empty, the oldest Trace goes next" + ); + assert!( + !lines.contains(&"info-oldest"), + "with Debug and Trace empty, the oldest Info goes last" + ); + for wanted in ["error-1", "error-2", "error-3"] { + assert!(lines.contains(&wanted), "the evicting record is retained"); + } + assert_eq!( + records.len(), + CAPACITY, + "eviction keeps the queue at capacity" + ); + } + + #[test] + fn eviction_never_displaces_a_more_important_record() { + let queue = LogQueue::new(); + queue.enqueue(LogPriority::Trace, line("trace-kept")); + queue.enqueue(LogPriority::Info, line("info-kept")); + for index in 0..(CAPACITY - 2) { + queue.enqueue(LogPriority::Warn, line(&format!("warn-{index}"))); + } + // A Trace may evict a Trace but never an Info or a Warn. + queue.enqueue(LogPriority::Trace, line("trace-new")); + queue.close(); + + let records = drain_all(&queue); + let lines: Vec<&str> = records.iter().map(|record| &*record.line).collect(); + assert!( + !lines.contains(&"trace-kept"), + "Trace evicts the oldest Trace" + ); + assert!( + lines.contains(&"info-kept"), + "Trace never evicts Info: no priority inversion" + ); + assert!( + lines.contains(&"trace-new"), + "the evicting record is retained" + ); + assert!( + (0..(CAPACITY - 2)).all(|index| lines.contains(&format!("warn-{index}").as_str())), + "Warn records are never eviction victims" + ); + } + + #[test] + fn a_producer_with_no_eligible_record_blocks_until_space_opens() { + let queue = Arc::new(LogQueue::new_for_test_with_wait( + CAPACITY, + LOG_LIMITS.max_queued_bytes, + Duration::from_secs(1), + )); + for index in 0..CAPACITY { + queue.enqueue(LogPriority::Warn, line(&format!("warn-{index}"))); + } + // A Debug record may evict only Debug, and the queue holds none: + // the producer blocks instead of dropping or inverting priority. + let producer_queue = Arc::clone(&queue); + let (done_tx, done_rx) = mpsc::channel(); + let producer = std::thread::spawn(move || { + producer_queue.enqueue(LogPriority::Debug, line("debug-blocked")); + done_tx.send(()).expect("report enqueue"); + }); + assert!( + done_rx.recv_timeout(Duration::from_millis(200)).is_err(), + "a full queue of Warn records blocks a Debug producer" + ); + + let batch = queue.take_batch(); + assert_eq!(batch.records.len(), BATCH, "the drain frees space"); + done_rx + .recv_timeout(Duration::from_secs(5)) + .expect("the blocked producer wakes once space opens"); + producer.join().expect("the producer joins"); + queue.close(); + + let records = drain_all(&queue); + assert!( + records + .iter() + .any(|record| &*record.line == "debug-blocked"), + "the blocked record lands once space opens" + ); + assert_eq!( + records + .iter() + .filter(|record| record.line.starts_with("warn-")) + .count(), + CAPACITY - BATCH, + "no Warn record was evicted to make room" + ); + } + + #[test] + fn a_protected_producer_times_out_into_preallocated_loss_accounting() { + let wait = Duration::from_millis(30); + let queue = LogQueue::new_for_test_with_wait(2, 32, wait); + queue.enqueue(LogPriority::Warn, line("warn-one")); + queue.enqueue(LogPriority::Error, line("error-two")); + + let started = std::time::Instant::now(); + queue.enqueue(LogPriority::Error, line("error-timeout")); + let elapsed = started.elapsed(); + assert!( + elapsed >= wait, + "the protected producer waits for its configured budget: {elapsed:?}" + ); + assert!( + elapsed < wait + Duration::from_millis(75), + "the protected producer stays near its configured upper bound: {elapsed:?}" + ); + + queue.close(); + let batch = queue.take_batch(); + assert_eq!( + batch + .records + .iter() + .map(|record| record.line.as_ref()) + .collect::>(), + ["warn-one", "error-two"], + "the timed-out record was never admitted" + ); + assert_eq!( + batch.summary.as_deref(), + Some( + "log pressure affected 1 record(s): dropped=1, debug=0, trace=0, info=0, truncated=0, rejected=1\n" + ), + "timeout loss uses the existing pressure summary storage" + ); + } + + #[test] + fn producer_deadline_includes_waiting_to_acquire_the_mutex() { + let wait = Duration::from_millis(40); + let queue = Arc::new(LogQueue::new_for_test_with_wait(2, 32, wait)); + queue.enqueue(LogPriority::Warn, line("warn-one")); + queue.enqueue(LogPriority::Error, line("error-two")); + + let lock_queue = Arc::clone(&queue); + let (locked_tx, locked_rx) = mpsc::sync_channel(0); + let (release_tx, release_rx) = mpsc::channel(); + let lock_holder = std::thread::spawn(move || { + lock_queue.hold_lock_for_test(&locked_tx, &release_rx); + }); + locked_rx + .recv_timeout(Duration::from_secs(5)) + .expect("the queue mutex is held"); + + let producer_queue = Arc::clone(&queue); + let (done_tx, done_rx) = mpsc::sync_channel(0); + let producer = std::thread::spawn(move || { + producer_queue.enqueue(LogPriority::Error, line("mutex-timeout")); + done_tx.send(()).expect("report bounded producer"); + }); + let bounded = done_rx.recv_timeout(wait + Duration::from_millis(75)); + release_tx.send(()).expect("release queue mutex"); + lock_holder.join().expect("the lock holder joins"); + producer.join().expect("the producer joins"); + assert!( + bounded.is_ok(), + "mutex acquisition is part of the producer's {wait:?} budget" + ); + + queue.close(); + let batch = queue.take_batch(); + assert_eq!( + batch.summary.as_deref(), + Some( + "log pressure affected 1 record(s): dropped=1, debug=0, trace=0, info=0, truncated=0, rejected=1\n" + ), + "a mutex-budget loss remains explicitly observable" + ); + } + + #[test] + fn a_batch_drains_256_records_in_global_sequence_order() { + let queue = LogQueue::new(); + let priorities = [ + LogPriority::Error, + LogPriority::Debug, + LogPriority::Info, + LogPriority::Warn, + LogPriority::Trace, + ]; + for index in 0..(BATCH * 2) { + queue.enqueue( + priorities[index % priorities.len()], + line(&format!("record-{index}")), + ); + } + + let batch = queue.take_batch(); + assert_eq!( + batch.records.len(), + BATCH, + "one drain swaps a bounded batch" + ); + assert!(!batch.done, "an open queue with records left is not done"); + for (position, record) in batch.records.iter().enumerate() { + assert_eq!( + record.sequence, position as u64, + "lane heads are merged by smallest global sequence" + ); + assert_eq!( + &*record.line, + format!("record-{position}"), + "chronological order is retained across lanes" + ); + } + queue.close(); + let rest = drain_all(&queue); + assert_eq!(rest.len(), BATCH, "the remainder drains after close"); + } + + #[test] + fn sequence_follows_admission_when_a_prepared_producer_is_paused() { + let queue = Arc::new(LogQueue::new_for_test(8, 128)); + let (prepared_tx, prepared_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let paused_queue = Arc::clone(&queue); + let paused = std::thread::spawn(move || { + paused_queue.enqueue_after( + LogPriority::Info, + line("prepared-first"), + FormatStatus::Complete, + || { + prepared_tx.send(()).expect("report prepared producer"); + release_rx.recv().expect("release prepared producer"); + }, + ); + }); + prepared_rx + .recv_timeout(Duration::from_secs(5)) + .expect("the first producer pauses before admission"); + + queue.enqueue(LogPriority::Warn, line("admitted-first")); + release_tx.send(()).expect("release first producer"); + paused.join().expect("the paused producer joins"); + queue.close(); + + let records = drain_all(&queue); + assert_eq!( + records + .iter() + .map(|record| (record.sequence, record.line.as_ref())) + .collect::>(), + [(0, "admitted-first"), (1, "prepared-first")], + "sequence is assigned atomically with successful admission" + ); + } + + #[test] + fn abandonment_counts_admission_boundary_record_exactly_once() { + let queue = Arc::new(LogQueue::new_for_test(8, 128)); + let producer_queue = Arc::clone(&queue); + let (admitted_tx, admitted_rx) = mpsc::sync_channel(0); + let (release_tx, release_rx) = mpsc::sync_channel(0); + let producer = std::thread::spawn(move || { + producer_queue.enqueue_around( + LogPriority::Warn, + line("admitted-before-timeout"), + FormatStatus::Complete, + || {}, + || { + admitted_tx + .send(()) + .expect("report the atomic admission boundary"); + release_rx.recv().expect("release the admitting producer"); + }, + ); + }); + admitted_rx + .recv_timeout(Duration::from_secs(5)) + .expect("the producer pauses after admission"); + + let loss = queue.abandon(); + assert_eq!( + loss, + ShutdownLoss { + abandoned_records: 1, + abandoned_summaries: 0, + unreported_pressure_records: 0, + }, + "one admitting producer is one undelivered record, not an active-plus-admitted double count" + ); + release_tx.send(()).expect("release the producer"); + producer.join().expect("the producer joins"); + assert_eq!( + queue.shutdown_loss_for_test(), + loss, + "the persisted shutdown accounting keeps the same exact snapshot" + ); + } + + #[test] + fn variable_size_pressure_obeys_record_and_byte_peaks() { + let count_queue = LogQueue::new_for_test(3, 100); + for text in ["aaaa", "bb", "ccc"] { + count_queue.enqueue(LogPriority::Debug, line(text)); + } + count_queue.enqueue(LogPriority::Error, line("e")); + assert_eq!( + count_queue.accounting_for_test(), + (3, 6, 9), + "record capacity evicts one old Debug while byte accounting stays exact" + ); + + let byte_queue = LogQueue::new_for_test(8, 10); + for text in ["aaaa", "bb", "ccc"] { + byte_queue.enqueue(LogPriority::Debug, line(text)); + } + byte_queue.enqueue(LogPriority::Error, line("1234567")); + assert_eq!( + byte_queue.accounting_for_test(), + (2, 10, 10), + "variable-size eviction admits only after enough exact bytes are freed" + ); + byte_queue.close(); + let records = drain_all(&byte_queue); + assert_eq!( + records + .iter() + .map(|record| record.line.as_ref()) + .collect::>(), + ["ccc", "1234567"], + "the oldest eligible records are evicted until the byte bound fits" + ); + } + + #[test] + fn pressure_summary_follows_the_admitted_tail_and_resets_for_a_second_episode() { + let queue = LogQueue::new_for_test(600, 600); + for _ in 0..600 { + queue.enqueue(LogPriority::Debug, line("d")); + } + queue.enqueue_formatted(LogPriority::Error, line("e"), FormatStatus::Truncated); + for _ in 0..4 { + queue.enqueue(LogPriority::Error, line("e")); + } + + let above_low_water = queue.take_batch(); + assert_eq!(above_low_water.records.len(), BATCH); + assert!( + above_low_water.summary.is_none(), + "the episode remains open above both half-capacity thresholds" + ); + let recovered = queue.take_batch(); + assert_eq!(recovered.records.len(), BATCH); + assert!( + recovered.summary.is_none(), + "the summary waits behind records admitted before recovery" + ); + assert_eq!( + queue.accounting_for_test().0, + 600 - (BATCH * 2), + "crossing low water leaves an admitted tail" + ); + + queue.enqueue(LogPriority::Info, line("admitted-after-recovery")); + let admitted_tail = queue.take_batch(); + assert_eq!( + admitted_tail.records.len(), + 600 - (BATCH * 2), + "only records older than the pending summary drain" + ); + assert!( + admitted_tail + .records + .iter() + .all(|record| record.line.as_ref() != "admitted-after-recovery"), + "a later admission cannot move ahead of the pending summary" + ); + assert_eq!( + admitted_tail.summary.as_deref(), + Some( + "log pressure affected 6 record(s): dropped=5, debug=5, trace=0, info=0, truncated=1, rejected=0\n" + ), + "the first episode closes immediately after its admitted tail" + ); + + queue.enqueue(LogPriority::Error, line(&"x".repeat(601))); + queue.close(); + let second_episode = queue.take_batch(); + assert_eq!( + second_episode + .records + .iter() + .map(|record| record.line.as_ref()) + .collect::>(), + ["admitted-after-recovery"], + "the later admission follows the first summary" + ); + assert_eq!( + second_episode.summary.as_deref(), + Some( + "log pressure affected 1 record(s): dropped=1, debug=0, trace=0, info=0, truncated=0, rejected=1\n" + ), + "the oversized protected record starts a clean second episode" + ); + assert!(second_episode.done); + } + + #[test] + fn a_byte_blocked_producer_wakes_after_drain() { + let queue = Arc::new(LogQueue::new_for_test_with_wait( + 4, + 4, + Duration::from_secs(1), + )); + queue.enqueue(LogPriority::Warn, line("wwww")); + + let drain_queue = Arc::clone(&queue); + let (drain_prepared_tx, drain_prepared_rx) = mpsc::channel(); + let (drain_release_tx, drain_release_rx) = mpsc::channel(); + let (drain_done_tx, drain_done_rx) = mpsc::channel(); + let drain_waiter = std::thread::spawn(move || { + drain_queue.enqueue_after( + LogPriority::Debug, + line("d"), + FormatStatus::Complete, + || { + drain_prepared_tx + .send(()) + .expect("report prepared producer"); + drain_release_rx.recv().expect("release prepared producer"); + }, + ); + drain_done_tx.send(()).expect("report drain wakeup"); + }); + drain_prepared_rx + .recv_timeout(Duration::from_secs(5)) + .expect("the byte-blocked producer reaches admission"); + drain_release_tx + .send(()) + .expect("release the byte-blocked producer"); + assert!( + drain_done_rx + .recv_timeout(Duration::from_millis(200)) + .is_err(), + "free record slots do not bypass the aggregate byte bound" + ); + + let freed = queue.take_batch(); + assert_eq!( + freed + .records + .iter() + .map(|record| record.line.as_ref()) + .collect::>(), + ["wwww"] + ); + drain_done_rx + .recv_timeout(Duration::from_secs(5)) + .expect("draining bytes wakes the producer"); + drain_waiter.join().expect("the drain waiter joins"); + let admitted = queue.take_batch(); + assert_eq!(admitted.records[0].line.as_ref(), "d"); + } + + #[test] + fn close_preaccounts_a_byte_blocked_protected_record() { + let queue = Arc::new(LogQueue::new_for_test_with_wait( + 4, + 4, + Duration::from_secs(1), + )); + queue.enqueue(LogPriority::Warn, line("wwww")); + let close_queue = Arc::clone(&queue); + let (close_prepared_tx, close_prepared_rx) = mpsc::channel(); + let (close_release_tx, close_release_rx) = mpsc::channel(); + let (close_done_tx, close_done_rx) = mpsc::channel(); + let close_waiter = std::thread::spawn(move || { + close_queue.enqueue_after( + LogPriority::Error, + line("z"), + FormatStatus::Complete, + || { + close_prepared_tx + .send(()) + .expect("report prepared producer"); + close_release_rx.recv().expect("release prepared producer"); + }, + ); + close_done_tx.send(()).expect("report close wakeup"); + }); + close_prepared_rx + .recv_timeout(Duration::from_secs(5)) + .expect("the closing producer reaches admission"); + close_release_tx + .send(()) + .expect("release the closing producer"); + assert!( + close_done_rx + .recv_timeout(Duration::from_millis(200)) + .is_err(), + "the producer blocks on bytes before close" + ); + + queue.close(); + close_done_rx + .recv_timeout(Duration::from_secs(5)) + .expect("close wakes the byte-blocked producer"); + close_waiter.join().expect("the close waiter joins"); + let remaining = queue.take_batch(); + assert_eq!( + remaining + .records + .iter() + .map(|record| record.line.as_ref()) + .collect::>(), + ["wwww"], + "close wakes the producer without admitting its record" + ); + assert_eq!( + remaining.summary.as_deref(), + Some( + "log pressure affected 1 record(s): dropped=1, debug=0, trace=0, info=0, truncated=0, rejected=1\n" + ), + "close pre-accounts the protected record before waking its producer" + ); + assert!(remaining.done); + } + + #[test] + fn pressure_emits_one_summary_after_the_queue_empties() { + let queue = LogQueue::new(); + for index in 0..CAPACITY { + queue.enqueue(LogPriority::Debug, line(&format!("debug-{index}"))); + } + for index in 0..10 { + queue.enqueue(LogPriority::Error, line(&format!("error-{index}"))); + } + queue.close(); + + let mut summaries = Vec::new(); + let mut total_records = 0; + loop { + let batch = queue.take_batch(); + total_records += batch.records.len(); + if let Some(summary) = batch.summary { + summaries.push(summary); + } + if batch.done { + break; + } + } + assert_eq!(total_records, CAPACITY, "eviction keeps the queue full"); + assert_eq!( + summaries.len(), + 1, + "one synthetic summary per pressure episode: {summaries:?}" + ); + assert!( + summaries[0].contains("10 record(s)") && summaries[0].contains("debug=10"), + "the summary counts evictions by level: {}", + summaries[0] + ); + } + + #[test] + fn a_queue_without_evictions_emits_no_summary() { + let queue = LogQueue::new(); + queue.enqueue(LogPriority::Info, line("only")); + queue.close(); + let batch = queue.take_batch(); + assert!(batch.summary.is_none(), "no pressure, no summary"); + assert!(batch.done); + } + + #[test] + fn a_closed_queue_drops_new_records() { + let queue = LogQueue::new(); + queue.enqueue(LogPriority::Info, line("before-close")); + queue.close(); + queue.enqueue(LogPriority::Error, line("after-close")); + let records = drain_all(&queue); + assert_eq!(records.len(), 1, "admission is closed"); + assert_eq!(&*records[0].line, "before-close"); + } + + #[test] + fn saturation_under_load_never_evicts_or_duplicates_warn_or_error() { + const PRODUCERS: u64 = 4; + const PER_PRODUCER: u64 = 10000; + let queue = Arc::new(LogQueue::new_for_test_with_wait( + CAPACITY, + LOG_LIMITS.max_queued_bytes, + Duration::from_secs(1), + )); + let (drained_tx, drained_rx) = mpsc::channel(); + let worker_queue = Arc::clone(&queue); + let worker = std::thread::spawn(move || { + loop { + let batch = worker_queue.take_batch(); + for record in batch.records { + drained_tx + .send(record.line) + .expect("report the drained line"); + } + if batch.done { + break; + } + // A slow sink: the producers outpace the drain, so the queue + // fills and the eviction and blocking paths fire. The worker + // never stops draining, so a blocked producer always wakes. + std::thread::sleep(Duration::from_millis(2)); + } + }); + + // Four producers push five times the capacity across every lane. + let producers: Vec<_> = (0..PRODUCERS) + .map(|id| { + let queue = Arc::clone(&queue); + std::thread::spawn(move || { + for index in 0..PER_PRODUCER { + let priority = match index % 5 { + 0 => LogPriority::Debug, + 1 => LogPriority::Trace, + 2 => LogPriority::Info, + 3 => LogPriority::Warn, + _ => LogPriority::Error, + }; + queue.enqueue(priority, line(&format!("p{id}-{priority:?}-{index}"))); + } + }) + }) + .collect(); + for producer in producers { + producer.join().expect("the producer joins"); + } + queue.close(); + worker.join().expect("the worker joins"); + + let drained: Vec = drained_rx.iter().map(|line| line.to_string()).collect(); + let unique: std::collections::HashSet<&String> = drained.iter().collect(); + assert_eq!( + drained.len(), + unique.len(), + "no record is written twice under saturation" + ); + assert!( + (drained.len() as u64) < PRODUCERS * PER_PRODUCER, + "saturation really evicted: {} of {} records retained", + drained.len(), + PRODUCERS * PER_PRODUCER + ); + for id in 0..PRODUCERS { + for index in (3..PER_PRODUCER).step_by(5) { + let warn = format!("p{id}-Warn-{index}"); + let error = format!("p{id}-Error-{}", index + 1); + assert!( + unique.contains(&warn), + "Warn survives saturation: missing {warn}" + ); + assert!( + unique.contains(&error), + "Error survives saturation: missing {error}" + ); + } + } + } +} diff --git a/crates/gateway-logging/src/redact.rs b/crates/gateway-logging/src/redact.rs new file mode 100644 index 00000000..83179a09 --- /dev/null +++ b/crates/gateway-logging/src/redact.rs @@ -0,0 +1,668 @@ +//! The privacy pass every queued record goes through. +//! +//! No log record may carry credentials, cookies, authorization headers, +//! environment values, request bodies, audio, transcript text, prompts, or +//! full local model paths. Classified tracing fields are suppressed before +//! their values are formatted. The bounded text pass remains at the queue +//! chokepoint for authorization values, assignments, and dependency errors +//! embedded in unstructured messages. +//! +//! The patterns are ASCII and matched case-insensitively where a header +//! name is involved; redaction never reorders or truncates the rest of +//! the line. + +/// The mask replacing a sensitive value. +pub(crate) const REDACTED: &str = "[redacted]"; + +const SENSITIVE_FIELDS: &[&str] = &[ + "access_token", + "api_key", + "audio", + "auth", + "authorization", + "base_url", + "body", + "config_path", + "cookie", + "cookies", + "credential", + "credentials", + "endpoint_url", + "file_path", + "headers", + "model_path", + "password", + "path", + "payload", + "prompt", + "prompts", + "proxy_authorization", + "refresh_token", + "request_body", + "request_url", + "response_body", + "response_url", + "secret", + "set_cookie", + "system_prompt", + "token", + "transcript", + "uri", + "url", + "user_prompt", +]; + +const SENSITIVE_ALIAS_SUFFIXES: &[&str] = &[ + "data", "field", "header", "headers", "raw", "text", "value", "values", +]; + +/// Whether a tracing field is classified and must never format its value. +pub(crate) fn is_sensitive_field(name: &str) -> bool { + let leaf = name + .rsplit(['.', ':']) + .next() + .unwrap_or(name) + .trim_start_matches("r#"); + SENSITIVE_FIELDS.iter().any(|candidate| { + leaf.eq_ignore_ascii_case(candidate) + || leaf + .get(..leaf.len().saturating_sub(candidate.len())) + .is_some_and(|prefix| { + leaf.get(prefix.len()..) + .is_some_and(|suffix| suffix.eq_ignore_ascii_case(candidate)) + && prefix.ends_with(['_', '-']) + }) + || sensitive_component_alias(leaf, candidate) + }) +} + +fn sensitive_component_alias(name: &str, component: &str) -> bool { + let mut from = 0; + while let Some(start) = find_ascii(name, component, from) { + let end = start + component.len(); + let left_boundary = start == 0 + || name + .as_bytes() + .get(start - 1) + .is_some_and(|byte| matches!(byte, b'_' | b'-')); + let right_boundary = end == name.len() + || name + .as_bytes() + .get(end) + .is_some_and(|byte| matches!(byte, b'_' | b'-')); + if left_boundary && right_boundary { + let suffix = name[end..].trim_start_matches(['_', '-']); + if !suffix.is_empty() + && suffix.split(['_', '-']).all(|part| { + SENSITIVE_ALIAS_SUFFIXES + .iter() + .any(|suffix| part.eq_ignore_ascii_case(suffix)) + }) + { + return true; + } + } + from = end; + } + false +} + +/// Fixed-capacity valid UTF-8 used while redaction may expand masks. +#[derive(Debug)] +pub(crate) struct RedactedLine { + storage: Box<[u8]>, + len: usize, + truncated: bool, +} + +impl RedactedLine { + fn new(capacity: usize) -> Self { + #[cfg(test)] + crate::allocation_tracking::record(capacity); + Self { + storage: vec![0; capacity].into_boxed_slice(), + len: 0, + truncated: false, + } + } + + fn capacity(&self) -> usize { + self.storage.len() + } + + fn push_str(&mut self, text: &str) { + let remaining = self.capacity().saturating_sub(self.len); + let mut retained = remaining.min(text.len()); + while !text.is_char_boundary(retained) { + retained -= 1; + } + self.storage[self.len..self.len + retained].copy_from_slice(&text.as_bytes()[..retained]); + self.len += retained; + self.truncated |= retained < text.len(); + } + + fn truncate(&mut self, mut len: usize) { + len = len.min(self.len); + while len < self.len && self.storage[len] & 0b1100_0000 == 0b1000_0000 { + len -= 1; + } + self.len = len; + } + + /// Adds the truncation marker when either formatting or redaction + /// omitted bytes and returns exact-size queue storage. + pub(crate) fn finish( + mut self, + marker: &str, + formatter_truncated: bool, + ) -> Option<(Box, bool)> { + let truncated = formatter_truncated || self.truncated; + if truncated { + let payload_limit = self.capacity().saturating_sub(marker.len()); + self.truncate(payload_limit); + self.truncated = false; + self.push_str(marker); + debug_assert!( + !self.truncated, + "the configured record bound fits the marker" + ); + } + let mut bytes = self.storage.into_vec(); + bytes.truncate(self.len); + #[cfg(test)] + crate::allocation_tracking::record(self.len); + let text = String::from_utf8(bytes).ok()?; + Some((text.into_boxed_str(), truncated)) + } +} + +/// Masks the sensitive shapes `text` could carry without permitting any +/// intermediate output buffer to exceed `capacity`. +pub(crate) fn redact_line_bounded(text: &str, capacity: usize) -> RedactedLine { + let mut out = RedactedLine::new(capacity); + let mut cursor = 0; + while let Some(span) = next_sensitive_span(text, cursor) { + out.push_str(&text[cursor..span.start]); + out.push_str(REDACTED); + cursor = span.end; + } + out.push_str(&text[cursor..]); + out +} + +/// Masks the sensitive shapes `text` could carry and returns the result. +/// Tests use this convenience path; production supplies its strict record +/// capacity through [`redact_line_bounded`]. +#[cfg(test)] +pub(crate) fn redact_line(text: &str) -> String { + let capacity = text.len().saturating_mul(REDACTED.len()); + redact_line_bounded(text, capacity) + .finish("", false) + .filter(|(_, truncated)| !truncated) + .map_or_else(String::new, |(text, _)| text.into()) +} + +/// The first position where `needle` matches `haystack` at or after +/// `from`, comparing ASCII case-insensitively. Byte offsets stay valid +/// because only ASCII needles are ever searched. +fn find_ascii(haystack: &str, needle: &str, from: usize) -> Option { + if from > haystack.len() || needle.len() > haystack.len().saturating_sub(from) { + return None; + } + haystack.as_bytes()[from..] + .windows(needle.len()) + .position(|window| window.eq_ignore_ascii_case(needle.as_bytes())) + .map(|offset| from + offset) +} + +#[derive(Debug, Clone, Copy)] +struct SensitiveSpan { + start: usize, + end: usize, +} + +fn next_sensitive_span(text: &str, from: usize) -> Option { + let mut cursor = from; + while cursor < text.len() { + for header in ["authorization:", "cookie:", "set-cookie:"] { + if starts_ascii(text, header, cursor) { + let mut value_start = cursor + header.len(); + while text + .as_bytes() + .get(value_start) + .is_some_and(|byte| matches!(byte, b' ' | b'\t')) + { + value_start += 1; + } + let value_end = text[value_start..] + .find('\n') + .map_or(text.len(), |newline| value_start + newline); + if value_end > value_start { + return Some(SensitiveSpan { + start: value_start, + end: value_end, + }); + } + } + } + for scheme in ["bearer ", "basic "] { + if starts_ascii(text, scheme, cursor) { + let token_start = cursor + scheme.len(); + let token_end = text[token_start..] + .find(|character: char| { + character.is_whitespace() + || matches!(character, ',' | ';' | ')' | ']' | '}') + }) + .map_or(text.len(), |end| token_start + end); + if token_end > token_start { + return Some(SensitiveSpan { + start: token_start, + end: token_end, + }); + } + } + } + for field in SENSITIVE_FIELDS { + if starts_ascii(text, field, cursor) + && let Some(span) = assignment_span_at(text, field, cursor) + { + return Some(span); + } + } + if let Some(span) = url_span_at(text, cursor) { + return Some(span); + } + if let Some(span) = local_path_span_at(text, cursor) { + return Some(span); + } + cursor += text[cursor..].chars().next().map_or(1, char::len_utf8); + } + None +} + +fn starts_ascii(text: &str, needle: &str, at: usize) -> bool { + text.as_bytes() + .get(at..at.saturating_add(needle.len())) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(needle.as_bytes())) +} + +fn assignment_span_at(text: &str, field: &str, start: usize) -> Option { + let after_key = start + field.len(); + let bytes = text.as_bytes(); + if start != 0 && bytes[start - 1].is_ascii_alphanumeric() + || bytes.get(after_key).is_some_and(u8::is_ascii_alphanumeric) + { + return None; + } + let mut cursor = after_key; + if cursor < bytes.len() && bytes[cursor] == b'"' { + cursor += 1; + } + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() && bytes[cursor] != b'\n' { + cursor += 1; + } + if cursor >= bytes.len() || (bytes[cursor] != b'=' && bytes[cursor] != b':') { + return None; + } + cursor += 1; + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() && bytes[cursor] != b'\n' { + cursor += 1; + } + let quote = bytes + .get(cursor) + .copied() + .filter(|byte| matches!(byte, b'"' | b'\'')); + if quote.is_some() { + cursor += 1; + } + let value_start = cursor; + let value_end = if let Some(quote) = quote { + quoted_value_end(text.as_bytes(), value_start, quote) + } else { + text[value_start..] + .find(|character: char| { + character.is_whitespace() + || matches!(character, '"' | '\'' | ',' | '&' | ';' | ')' | ']' | '}') + }) + .map_or(text.len(), |end| value_start + end) + }; + (value_end > value_start).then_some(SensitiveSpan { + start: value_start, + end: value_end, + }) +} + +fn quoted_value_end(text: &[u8], start: usize, quote: u8) -> usize { + let mut escaped = false; + for (offset, byte) in text[start..].iter().copied().enumerate() { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == quote { + return start + offset; + } + } + text.len() +} + +fn url_span_at(text: &str, start: usize) -> Option { + let bytes = text.as_bytes(); + if !bytes.get(start).is_some_and(u8::is_ascii_alphabetic) { + return None; + } + let mut marker = start + 1; + while bytes + .get(marker) + .is_some_and(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.')) + { + marker += 1; + } + if bytes.get(marker..marker + 3) != Some(b"://") { + return None; + } + Some(SensitiveSpan { + start, + end: sensitive_token_end(text, marker + 3), + }) +} + +fn local_path_span_at(text: &str, start: usize) -> Option { + const MODEL_EXTENSIONS: &[&str] = &[ + ".bin", + ".ggml", + ".gguf", + ".onnx", + ".pt", + ".pth", + ".safetensors", + ]; + let bytes = text.as_bytes(); + let boundary = start == 0 + || bytes[start - 1].is_ascii_whitespace() + || matches!( + bytes[start - 1], + b'"' | b'\'' | b'(' | b'[' | b'{' | b'=' | b':' + ); + let windows = start + 2 < bytes.len() + && bytes[start].is_ascii_alphabetic() + && bytes[start + 1] == b':' + && matches!(bytes[start + 2], b'/' | b'\\'); + let unc = start + 1 < bytes.len() + && matches!(bytes[start], b'/' | b'\\') + && bytes[start + 1] == bytes[start]; + let unix = bytes[start] == b'/' + && bytes.get(start + 1).is_some_and(|byte| { + !byte.is_ascii_whitespace() && !matches!(byte, b'/' | b')' | b']' | b'}') + }); + if boundary && (windows || unc || unix) { + let end = sensitive_token_end(text, start + usize::from(windows) * 2); + if MODEL_EXTENSIONS.iter().any(|extension| { + let path = &text[start..end]; + path.get(path.len().saturating_sub(extension.len())..) + .is_some_and(|suffix| suffix.eq_ignore_ascii_case(extension)) + }) { + return Some(SensitiveSpan { start, end }); + } + } + None +} + +fn sensitive_token_end(text: &str, content_start: usize) -> usize { + text[content_start..] + .find(|character: char| { + character.is_whitespace() + || matches!(character, '"' | '\'' | ',' | ')' | ']' | '}' | '<' | '>') + }) + .map_or(text.len(), |end| content_start + end) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_authorization_header_value_is_masked() { + let redacted = redact_line("sending Authorization: Bearer abc123secret to upstream"); + assert!( + !redacted.contains("abc123secret"), + "the bearer token never reaches a record: {redacted}" + ); + assert!( + redacted.contains("Authorization: [redacted]"), + "the header name survives so the log stays legible: {redacted}" + ); + } + + #[test] + fn a_lowercase_authorization_header_is_masked() { + let redacted = redact_line("header authorization: basic dXNlcg== rejected"); + assert!( + !redacted.contains("dXNlcg=="), + "HTTP/2's lowercase header names redact the same way: {redacted}" + ); + } + + #[test] + fn cookie_and_set_cookie_values_are_masked() { + let redacted = redact_line("request Cookie: session=xyz789; other=1\nnext line"); + assert!( + !redacted.contains("xyz789"), + "the cookie value never reaches a record: {redacted}" + ); + assert!( + redacted.contains("next line"), + "redaction stops at the end of the header's line: {redacted}" + ); + let redacted = redact_line("response Set-Cookie: token=abc; HttpOnly"); + assert!( + !redacted.contains("token=abc"), + "a set-cookie value never reaches a record: {redacted}" + ); + } + + #[test] + fn a_bare_bearer_token_is_masked() { + let redacted = redact_line("upstream rejected Bearer tok_live_51xyz with 401"); + assert!( + !redacted.contains("tok_live_51xyz"), + "a bearer token without its header name is still masked: {redacted}" + ); + assert!( + redacted.contains("Bearer [redacted]"), + "the scheme survives: {redacted}" + ); + } + + #[test] + fn api_key_assignments_are_masked_in_toml_and_json_shapes() { + for (line, secret) in [ + ("api_key = \"toml-secret\"", "toml-secret"), + ("api_key=\"compact-secret\"", "compact-secret"), + ("{\"api_key\": \"json-secret\"}", "json-secret"), + ("api_key: bare-secret, done", "bare-secret"), + ] { + let redacted = redact_line(line); + assert!( + !redacted.contains(secret), + "the api_key value never reaches a record: {redacted}" + ); + assert!( + redacted.contains("api_key"), + "the field name survives: {redacted}" + ); + } + } + + #[test] + fn adversarial_unstructured_values_are_masked() { + for (line, secret) in [ + ( + "authorization: Basic YmFzaWMtdXNlcjpiYXNpYy1zZWNyZXQ=", + "YmFzaWMtdXNlcjpiYXNpYy1zZWNyZXQ=", + ), + ( + "proxy rejected Basic YmFyZS11c2VyOmJhcmUtc2VjcmV0", + "YmFyZS11c2VyOmJhcmUtc2VjcmV0", + ), + ( + "dependency rejected Bearer bearer-secret, retrying", + "bearer-secret", + ), + ("cookie=session=cookie-secret; theme=dark", "cookie-secret"), + ("set-cookie='set-cookie-secret'", "set-cookie-secret"), + ("url=https://user:url-secret@example.test/v1", "url-secret"), + ( + "GET https://user:embedded-url-secret@example.test/v1", + "embedded-url-secret", + ), + ("prompt=\"first line\nprompt-secret\"", "prompt-secret"), + ( + "prompt=\"escaped \\\" quote then escaped-prompt-secret\"", + "escaped-prompt-secret", + ), + ( + "model_path=C:\\private\\path-secret\\model.gguf", + "path-secret", + ), + ( + "payload={\"outer\":{\"token\":\"payload-secret\"}}", + "payload-secret", + ), + ( + "outer error\ncaused by: request failed\ncaused by: api_key=nested-secret", + "nested-secret", + ), + ( + "outer error\ncaused by: GET https://host/private-route?opaque-secret", + "opaque-secret", + ), + ( + "outer error\ncaused by: model load failed at C:\\private\\model-secret.gguf", + "model-secret", + ), + ( + "outer error\ncaused by: model load failed at /private/models/unix-secret.gguf", + "unix-secret", + ), + ] { + let redacted = redact_line(line); + assert!( + !redacted.contains(secret), + "protected text survives redaction: {redacted}" + ); + assert!( + redacted.contains(REDACTED), + "the mask marks the removed value: {redacted}" + ); + } + } + + #[test] + fn structured_field_classification_uses_whole_components() { + for field in [ + "authorization", + "authorization_header", + "cookie_header", + "gateway_api_key", + "request.headers", + "request_token_value", + "upstream-url", + "system_prompt", + "config_path", + "request_body", + "secret", + ] { + assert!(is_sensitive_field(field), "{field} must be classified"); + } + for field in [ + "message", + "profile", + "token_count", + "body_count", + "url_status", + "secretary", + ] { + assert!( + !is_sensitive_field(field), + "{field} is an ordinary diagnostic field" + ); + } + } + + #[test] + fn mixed_patterns_cannot_leak_partial_secrets_at_any_capacity_boundary() { + const FIRST_SECRET: &str = "a"; + const URL_SECRET: &str = "capacity-boundary-url-secret"; + let line = "api_key=a then https://user:capacity-boundary-url-secret@example.test/private"; + + for capacity in (REDACTED.len() + 1)..line.len() { + let output = redact_line_bounded(line, capacity) + .finish("", false) + .expect("ASCII input remains valid"); + assert!( + !output.0.contains("api_key=a"), + "the short first secret is masked at capacity {capacity}: {}", + output.0 + ); + for fragment_len in 4..=URL_SECRET.len() { + assert!( + !output.0.contains(&URL_SECRET[..fragment_len]), + "a URL secret prefix survived at capacity {capacity}: {}", + output.0 + ); + } + assert_ne!( + output.0.as_ref(), + FIRST_SECRET, + "the first secret is never emitted by itself" + ); + } + } + + #[test] + fn unlabeled_urls_and_local_paths_are_replaced_whole_in_error_chains() { + let redacted = redact_line( + "dependency failed\ncaused by: https://host/private-route?opaque\ncaused by: C:\\private\\model.gguf\ncaused by: /opt/private/model.gguf", + ); + for protected in [ + "https://host/private-route?opaque", + "C:\\private\\model.gguf", + "/opt/private/model.gguf", + ] { + assert!( + !redacted.contains(protected), + "an unlabeled URL or path survived: {redacted}" + ); + } + assert_eq!( + redacted.matches(REDACTED).count(), + 3, + "each complete protected location becomes one mask" + ); + } + + #[test] + fn an_ordinary_line_passes_through_unchanged() { + for line in [ + "loaded profile main with 2 models; bind 127.0.0.1:8081", + "logging to C:\\Users\\operator\\.promptforge\\logs\\gateway.log", + ] { + assert_eq!( + redact_line(line), + line, + "a line without a sensitive shape is byte-identical" + ); + } + } + + #[test] + fn a_field_name_mention_without_a_value_is_not_a_leak() { + let line = "the api_key field is required"; + assert_eq!( + redact_line(line), + line, + "naming the field redacts nothing: no assignment follows" + ); + } +} diff --git a/crates/gateway-logging/src/runtime.rs b/crates/gateway-logging/src/runtime.rs new file mode 100644 index 00000000..93df7019 --- /dev/null +++ b/crates/gateway-logging/src/runtime.rs @@ -0,0 +1,481 @@ +//! The owning handle: queues, sink, rotation, and the worker thread's +//! lifecycle. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use crate::config::{LOG_LIMITS, LogConfig}; +use crate::error::LogError; +use crate::queue::{LogQueue, ShutdownLoss}; +use crate::worker::{LogWorker, open_log_file}; +use crate::writer::LogWriter; + +const MAX_EMERGENCY_START_WAIT: Duration = Duration::from_millis(10); + +/// The running log pipeline: the bounded queue, the rotated file sink, and +/// the worker thread that drains one to the other. +/// +/// Created by [`start`](Self::start), cloned out as [`LogWriter`]s through +/// [`writer`](Self::writer), and closed by [`shutdown`](Self::shutdown), +/// which the caller runs last so a healthy sink receives final records +/// without allowing a stalled sink to hold process exit forever. +/// +/// # Examples +/// ``` +/// # let dir = std::env::temp_dir().join(concat!("gateway-logging-doc-runtime-", env!("CARGO_PKG_VERSION"))); +/// let runtime = gateway_logging::LogRuntime::start(gateway_logging::LogConfig::new(&dir))?; +/// assert!(runtime.path().ends_with("gateway.log")); +/// runtime.shutdown()?; +/// # std::fs::remove_dir_all(&dir).ok(); +/// # Ok::<(), gateway_logging::LogError>(()) +/// ``` +#[derive(Debug)] +pub struct LogRuntime { + queue: Arc, + worker: Option, + path: PathBuf, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ShutdownOutcome { + Joined, + Detached(ShutdownLoss), +} + +impl LogRuntime { + /// Rotates any existing log, opens a fresh `gateway.log` under + /// `/logs`, and spawns the single worker thread. + /// + /// # Errors + /// Returns [`LogError`] when the logs directory cannot be created, the + /// existing log cannot be rotated, the fresh file cannot be opened, or + /// the worker thread cannot be spawned; classify with + /// [`LogError::is_io`]. + /// + /// # Examples + /// ```no_run + /// let runtime = gateway_logging::LogRuntime::start( + /// gateway_logging::LogConfig::new("/home/user/.promptforge"), + /// )?; + /// # Ok::<(), gateway_logging::LogError>(()) + /// ``` + pub fn start(config: LogConfig) -> Result { + let state_dir = config.into_state_dir(); + let (path, file) = open_log_file(&state_dir) + .map_err(|error| LogError::open(state_dir.join("logs/gateway.log"), error))?; + let queue = Arc::new(LogQueue::new()); + let worker = LogWorker::spawn(Arc::clone(&queue), file).map_err(LogError::spawn)?; + Ok(Self { + queue, + worker: Some(worker), + path, + }) + } + + /// A cloneable factory for the fmt layer's per-event writers. + /// + /// # Examples + /// ``` + /// # let dir = std::env::temp_dir().join(concat!("gateway-logging-doc-getwriter-", env!("CARGO_PKG_VERSION"))); + /// let runtime = gateway_logging::LogRuntime::start(gateway_logging::LogConfig::new(&dir))?; + /// let writer = runtime.writer(); + /// runtime.shutdown()?; + /// # std::fs::remove_dir_all(&dir).ok(); + /// # Ok::<(), gateway_logging::LogError>(()) + /// ``` + #[must_use] + pub fn writer(&self) -> LogWriter { + LogWriter::new(Arc::clone(&self.queue)) + } + + /// The path of the log file this run writes. + /// + /// # Examples + /// ``` + /// # let dir = std::env::temp_dir().join(concat!("gateway-logging-doc-path-", env!("CARGO_PKG_VERSION"))); + /// let runtime = gateway_logging::LogRuntime::start(gateway_logging::LogConfig::new(&dir))?; + /// assert_eq!(runtime.path().file_name().and_then(|name| name.to_str()), Some("gateway.log")); + /// runtime.shutdown()?; + /// # std::fs::remove_dir_all(&dir).ok(); + /// # Ok::<(), gateway_logging::LogError>(()) + /// ``` + #[must_use] + pub fn path(&self) -> &Path { + &self.path + } + + /// Closes admission and gives the worker the shared shutdown budget to + /// drain and flush. A healthy sink preserves every admitted record and + /// joins. After the budget expires, outstanding delivery is counted, an + /// emergency stderr diagnostic is attempted on a detached helper, and + /// the stalled worker is detached. Records enqueued after this call are + /// dropped. + /// + /// # Errors + /// Returns [`LogError`] when the worker thread panicked instead of + /// draining cleanly. + /// + /// # Examples + /// ``` + /// # let dir = std::env::temp_dir().join(concat!("gateway-logging-doc-shutdown-", env!("CARGO_PKG_VERSION"))); + /// let runtime = gateway_logging::LogRuntime::start(gateway_logging::LogConfig::new(&dir))?; + /// runtime.shutdown()?; + /// # std::fs::remove_dir_all(&dir).ok(); + /// # Ok::<(), gateway_logging::LogError>(()) + /// ``` + pub fn shutdown(self) -> Result<(), LogError> { + self.shutdown_with(LOG_LIMITS.shutdown_wait, write_emergency_diagnostic) + .map(|_| ()) + } + + fn shutdown_with( + self, + wait: Duration, + emergency_diagnostic: impl FnOnce(ShutdownLoss) + Send + 'static, + ) -> Result { + self.shutdown_with_waiter(wait, emergency_diagnostic, wait_for_worker_until) + } + + fn shutdown_with_waiter( + mut self, + wait: Duration, + emergency_diagnostic: impl FnOnce(ShutdownLoss) + Send + 'static, + mut wait_for_worker: impl FnMut(&LogWorker, Instant) -> bool, + ) -> Result { + let started = Instant::now(); + let shutdown_deadline = started.checked_add(wait).unwrap_or(started); + let emergency_reserve = wait.min(MAX_EMERGENCY_START_WAIT); + let worker_deadline = shutdown_deadline + .checked_sub(emergency_reserve) + .unwrap_or(started); + + if !self.queue.close_until(worker_deadline) { + let loss = self.queue.abandon(); + attempt_emergency_diagnostic(loss, emergency_diagnostic, shutdown_deadline); + return Ok(ShutdownOutcome::Detached(loss)); + } + if let Some(worker) = self.worker.take() { + if !wait_for_worker(&worker, worker_deadline) { + let loss = self.queue.abandon(); + attempt_emergency_diagnostic(loss, emergency_diagnostic, shutdown_deadline); + drop(worker); + return Ok(ShutdownOutcome::Detached(loss)); + } + worker.join().map_err(|_| LogError::worker_panicked())?; + } + Ok(ShutdownOutcome::Joined) + } +} + +fn wait_for_worker_until(worker: &LogWorker, deadline: Instant) -> bool { + while !worker.is_finished() { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return false; + } + std::thread::park_timeout(remaining.min(Duration::from_millis(1))); + } + true +} + +/// Isolates stderr from the shutdown caller because a redirected console can +/// itself stall. The start handshake consumes only the reserved tail of the +/// shared shutdown budget. Failure to spawn leaves the loss accounted in the +/// queue without risking an unbounded fallback write. +fn attempt_emergency_diagnostic( + loss: ShutdownLoss, + diagnostic: impl FnOnce(ShutdownLoss) + Send + 'static, + deadline: Instant, +) { + let (started_tx, started_rx) = std::sync::mpsc::sync_channel(0); + let spawned = std::thread::Builder::new() + .name("gateway-log-emergency".to_string()) + .spawn(move || { + let _ = started_tx.send(()); + diagnostic(loss); + }); + if spawned.is_ok() { + let remaining = deadline.saturating_duration_since(Instant::now()); + if !remaining.is_zero() { + let _ = started_rx.recv_timeout(remaining); + } + } +} + +fn write_emergency_diagnostic(loss: ShutdownLoss) { + use std::io::Write as _; + + let _ = writeln!( + std::io::stderr().lock(), + "gateway logging shutdown timed out: abandoned_records={}, abandoned_summaries={}, unreported_pressure_records={}", + loss.abandoned_records, + loss.abandoned_summaries, + loss.unreported_pressure_records, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::worker::StallPoint; + use std::io::Write as _; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + use tracing_subscriber::fmt::MakeWriter as _; + + struct TempStateDir(PathBuf); + + impl TempStateDir { + fn new(test: &str) -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "gateway-logging-{test}-{}-{id}", + std::process::id() + )); + std::fs::create_dir_all(&dir).expect("create the temp state dir"); + Self(dir) + } + } + + impl Drop for TempStateDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn shutdown_drains_flushes_and_joins() { + let temp = TempStateDir::new("shutdown"); + let runtime = LogRuntime::start(LogConfig::new(&temp.0)).expect("start the runtime"); + let path = runtime.path().to_path_buf(); + for index in 0..600 { + let mut event = runtime.writer().make_writer(); + writeln!(event, "record-{index}").expect("buffered write"); + } + runtime.shutdown().expect("shutdown drains and joins"); + + let contents = std::fs::read_to_string(&path).expect("read the log"); + for index in 0..600 { + assert!( + contents.contains(&format!("record-{index}")), + "every queued record survived shutdown: missing record-{index}" + ); + } + } + + #[test] + fn start_rotates_the_previous_run_log() { + let temp = TempStateDir::new("start-rotation"); + std::fs::create_dir_all(temp.0.join("logs")).expect("logs dir"); + std::fs::write(temp.0.join("logs/gateway.log"), "previous run").expect("seed log"); + + let runtime = LogRuntime::start(LogConfig::new(&temp.0)).expect("start the runtime"); + assert_eq!( + std::fs::read_to_string(temp.0.join("logs/gateway.log.1")).expect("rotated log"), + "previous run", + "start rotates the previous run's log to .1" + ); + runtime.shutdown().expect("shutdown"); + } + + #[test] + fn shutdown_writes_every_record_in_sequence_before_the_join_returns() { + let temp = TempStateDir::new("shutdown-order"); + let runtime = LogRuntime::start(LogConfig::new(&temp.0)).expect("start the runtime"); + let path = runtime.path().to_path_buf(); + for index in 0..300 { + let mut event = runtime.writer().make_writer(); + writeln!(event, "ordered-{index}").expect("buffered write"); + } + // After shutdown returns, the drain, the flush, and the join have + // all completed: the file holds every record in enqueue order. + runtime.shutdown().expect("shutdown drains and joins"); + + let contents = std::fs::read_to_string(&path).expect("read the log"); + let lines: Vec<&str> = contents.lines().collect(); + assert_eq!(lines.len(), 300, "the flush preceded the join's return"); + for (position, line) in lines.iter().enumerate() { + assert_eq!( + *line, + format!("ordered-{position}"), + "the file's order is the global enqueue sequence" + ); + } + } + + fn assert_stalled_shutdown(point: StallPoint) { + let queue = Arc::new(LogQueue::new_for_test_with_wait( + 8, + 128, + Duration::from_millis(10), + )); + let (worker, stalled_sink) = + LogWorker::spawn_stalled(Arc::clone(&queue), point).expect("spawn stalled worker"); + let runtime = LogRuntime { + queue: Arc::clone(&queue), + worker: Some(worker), + path: PathBuf::from("stalled.log"), + }; + + queue.enqueue(crate::queue::LogPriority::Error, Box::from("in-flight")); + stalled_sink + .wait_until_stalled(Duration::from_secs(5)) + .expect("the sink stalls on the first admitted record"); + for index in 0..8 { + queue.enqueue( + crate::queue::LogPriority::Warn, + format!("queued-{index}").into_boxed_str(), + ); + } + queue.enqueue( + crate::queue::LogPriority::Error, + Box::from("producer-timeout"), + ); + + let (diagnostic_tx, diagnostic_rx) = mpsc::sync_channel(1); + let (release_diagnostic_tx, release_diagnostic_rx) = mpsc::channel(); + let (deadline_tx, deadline_rx) = mpsc::sync_channel(1); + let (outcome_tx, outcome_rx) = mpsc::sync_channel(1); + let shutdown = std::thread::spawn(move || { + let outcome = runtime.shutdown_with_waiter( + Duration::from_millis(40), + move |diagnostic| { + diagnostic_tx + .send(diagnostic) + .expect("report emergency diagnostic"); + release_diagnostic_rx + .recv() + .expect("hold the emergency sink stalled"); + }, + |worker, deadline| { + assert!(!worker.is_finished(), "the selected sink point is stalled"); + deadline_tx + .send(deadline) + .expect("report the injected worker deadline"); + false + }, + ); + outcome_tx.send(outcome).expect("report shutdown outcome"); + }); + let outcome = outcome_rx + .recv_timeout(Duration::from_secs(1)) + .expect("the outer watchdog observes bounded shutdown") + .expect("a timeout is an accounted shutdown outcome"); + let worker_deadline = deadline_rx + .recv_timeout(Duration::from_secs(1)) + .expect("the deadline seam was exercised"); + assert!( + worker_deadline.saturating_duration_since(Instant::now()) <= Duration::from_millis(30), + "the worker receives no more than the configured budget minus emergency reserve" + ); + + let ShutdownOutcome::Detached(loss) = outcome else { + panic!("the permanently stalled worker must detach"); + }; + assert_eq!(loss.abandoned_records, 9, "one in-flight and eight queued"); + assert_eq!( + loss.abandoned_summaries, 1, + "the unflushed pressure episode would have produced one summary" + ); + assert_eq!( + loss.unreported_pressure_records, 1, + "the timed-out producer remains explicitly accounted" + ); + assert_eq!( + queue.shutdown_loss_for_test(), + loss, + "shutdown loss remains in preallocated queue accounting" + ); + assert_eq!( + diagnostic_rx + .recv_timeout(Duration::from_secs(1)) + .expect("the bounded emergency diagnostic is attempted"), + loss, + "the emergency diagnostic reports the accounted loss" + ); + + stalled_sink.release(); + release_diagnostic_tx + .send(()) + .expect("release the emergency sink"); + shutdown.join().expect("the shutdown caller joins"); + } + + #[test] + fn shutdown_deadline_detaches_a_stalled_write() { + assert_stalled_shutdown(StallPoint::Write); + } + + #[test] + fn shutdown_deadline_detaches_a_stalled_flush() { + assert_stalled_shutdown(StallPoint::Flush); + } + + #[test] + fn shutdown_deadline_includes_waiting_to_close_the_queue() { + let wait = Duration::from_millis(40); + let queue = Arc::new(LogQueue::new_for_test_with_wait(8, 128, wait)); + let (worker, stalled_sink) = + LogWorker::spawn_stalled(Arc::clone(&queue), StallPoint::Write) + .expect("spawn stalled worker"); + queue.enqueue(crate::queue::LogPriority::Error, Box::from("in-flight")); + stalled_sink + .wait_until_stalled(Duration::from_secs(5)) + .expect("the sink stalls outside the queue mutex"); + let runtime = LogRuntime { + queue: Arc::clone(&queue), + worker: Some(worker), + path: PathBuf::from("lock-stalled.log"), + }; + + let lock_queue = Arc::clone(&queue); + let (locked_tx, locked_rx) = mpsc::sync_channel(0); + let (release_lock_tx, release_lock_rx) = mpsc::channel(); + let lock_holder = std::thread::spawn(move || { + lock_queue.hold_lock_for_test(&locked_tx, &release_lock_rx); + }); + locked_rx + .recv_timeout(Duration::from_secs(5)) + .expect("the queue mutex is held"); + + let (diagnostic_tx, diagnostic_rx) = mpsc::sync_channel(1); + let (outcome_tx, outcome_rx) = mpsc::sync_channel(1); + let shutdown = std::thread::spawn(move || { + let outcome = runtime.shutdown_with(wait, move |diagnostic| { + diagnostic_tx + .send(diagnostic) + .expect("report emergency diagnostic"); + }); + outcome_tx.send(outcome).expect("report shutdown outcome"); + }); + let bounded = outcome_rx.recv_timeout(wait + Duration::from_millis(75)); + release_lock_tx.send(()).expect("release queue mutex"); + lock_holder.join().expect("the lock holder joins"); + let outcome = bounded + .expect("queue mutex acquisition stays inside the shutdown budget") + .expect("lock contention becomes an accounted shutdown timeout"); + let ShutdownOutcome::Detached(loss) = outcome else { + panic!("the mutex-stalled shutdown must detach"); + }; + assert_eq!( + loss.abandoned_records, 1, + "the in-flight record is accounted" + ); + assert_eq!( + loss.abandoned_summaries, 0, + "no pressure summary was pending" + ); + assert_eq!( + diagnostic_rx + .recv_timeout(Duration::from_secs(1)) + .expect("the emergency diagnostic receives the snapshot"), + loss, + "lock-free abandonment preserves exact accounting" + ); + + stalled_sink.release(); + shutdown.join().expect("the shutdown caller joins"); + } +} diff --git a/crates/gateway-logging/src/worker.rs b/crates/gateway-logging/src/worker.rs new file mode 100644 index 00000000..ad08fdd2 --- /dev/null +++ b/crates/gateway-logging/src/worker.rs @@ -0,0 +1,1658 @@ +//! The worker thread, the segmented file sink with its stderr fallback, +//! and startup plus size-triggered log rotation. + +use std::fs::{File, OpenOptions}; +use std::io::{self, BufWriter, Read as _, Seek as _, Write as _}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::thread::JoinHandle; + +use crate::config::{LOG_LIMITS, LogConfig, SEGMENT_TRUNCATION_MARKER}; +use crate::queue::LogQueue; + +#[derive(Debug, Clone, Copy)] +struct RotationLimits { + segment: u64, + aggregate: u64, + terminal_record: u64, +} + +#[derive(Debug, Clone, Copy)] +enum ReplacementMode { + Atomic, + RemoveThenRename, +} + +impl ReplacementMode { + const fn production() -> Self { + if cfg!(windows) { + Self::RemoveThenRename + } else { + Self::Atomic + } + } +} + +#[derive(Debug, Default)] +struct FaultInjector { + #[cfg(test)] + fail_at: Option, + #[cfg(test)] + calls: usize, + #[cfg(test)] + simulated_crash: bool, + #[cfg(test)] + failed_operation: Option<&'static str>, + #[cfg(test)] + commit_marker_written: bool, +} + +impl FaultInjector { + #[cfg_attr(not(test), allow(clippy::unused_self, clippy::unnecessary_wraps))] + fn checkpoint(&mut self, operation: &'static str) -> io::Result<()> { + #[cfg(not(test))] + let _ = operation; + #[cfg(test)] + { + self.calls += 1; + if self.fail_at == Some(self.calls) { + self.fail_at = None; + self.failed_operation = Some(operation); + return Err(io::Error::other(format!( + "injected filesystem failure at {operation}" + ))); + } + } + Ok(()) + } + + #[cfg_attr(not(test), allow(clippy::unused_self))] + fn is_simulated_crash(&self) -> bool { + #[cfg(test)] + { + self.simulated_crash && self.failed_operation.is_some() + } + #[cfg(not(test))] + { + false + } + } + + #[cfg_attr(not(test), allow(clippy::unused_self))] + fn record_commit_marker(&mut self) { + #[cfg(test)] + { + self.commit_marker_written = true; + } + } +} + +impl RotationLimits { + const fn production() -> Self { + Self { + segment: LOG_LIMITS.segment_bytes, + aggregate: LOG_LIMITS.aggregate_retained_bytes, + terminal_record: LOG_LIMITS.max_formatted_record_bytes as u64, + } + } + + fn validate(self) -> io::Result<()> { + let reserved = self + .terminal_record + .checked_add(SEGMENT_TRUNCATION_MARKER.len() as u64) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "log limits overflow"))?; + if self.segment < reserved || self.aggregate < self.segment { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "log limits cannot reserve one marked terminal record", + )); + } + Ok(()) + } +} + +/// Opens `/logs/gateway.log` fresh and returns the worker-owned +/// segmented sink. Existing files are normalized before the current log is +/// shifted to `.1`, so the first write of a restarted process begins inside +/// the same segment and aggregate budgets used at runtime. +/// +/// # Errors +/// Returns the I/O failure from creating the directory, normalizing or +/// rotating retained logs, or opening the fresh active segment. +pub(crate) fn open_log_file(state_dir: &Path) -> io::Result<(PathBuf, SegmentedFile)> { + open_log_file_with_limits(state_dir, RotationLimits::production()) +} + +fn open_log_file_with_limits( + state_dir: &Path, + limits: RotationLimits, +) -> io::Result<(PathBuf, SegmentedFile)> { + limits.validate()?; + let config = LogConfig::new(state_dir); + let logs = state_dir.join("logs"); + std::fs::create_dir_all(&logs)?; + let current = config.log_path(); + let retained = config.retained_log_paths(); + recover_rotation(¤t, &retained)?; + + compact_oversized_segment(¤t, limits.segment)?; + for path in &retained { + compact_oversized_segment(path, limits.segment)?; + } + let mut retained_bytes = retained.iter().try_fold(0u64, |total, path| { + Ok::<_, io::Error>(total.saturating_add(existing_file_len(path)?.unwrap_or(0))) + })?; + let current_bytes = existing_file_len(¤t)?.unwrap_or(0); + prune_oldest_for( + &retained, + &mut retained_bytes, + current_bytes, + limits.aggregate, + )?; + + if current_bytes != 0 { + rotate_files( + ¤t, + &retained, + &mut FaultInjector::default(), + ReplacementMode::production(), + )?; + retained_bytes = retained.iter().try_fold(0u64, |total, path| { + Ok::<_, io::Error>(total.saturating_add(existing_file_len(path)?.unwrap_or(0))) + })?; + } else { + File::create(¤t)?.sync_all()?; + } + let file = OpenOptions::new().append(true).open(¤t)?; + let sink = SegmentedFile { + current: current.clone(), + retained, + file: Some(BufWriter::new(file)), + current_bytes: 0, + retained_bytes, + limits, + }; + Ok((current, sink)) +} + +fn existing_file_len(path: &Path) -> io::Result> { + match path.metadata() { + Ok(metadata) if metadata.is_file() => Ok(Some(metadata.len())), + Ok(_) => Ok(None), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +fn compact_oversized_segment(path: &Path, segment_bytes: u64) -> io::Result<()> { + compact_oversized_segment_with( + path, + segment_bytes, + &mut FaultInjector::default(), + ReplacementMode::production(), + ) +} + +fn compact_oversized_segment_with( + path: &Path, + segment_bytes: u64, + fault: &mut FaultInjector, + replacement: ReplacementMode, +) -> io::Result<()> { + recover_replacement(path)?; + let Some(file_bytes) = existing_file_len(path)? else { + return Ok(()); + }; + if file_bytes <= segment_bytes { + return Ok(()); + } + let payload_bytes = segment_bytes + .checked_sub(SEGMENT_TRUNCATION_MARKER.len() as u64) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "segment marker exceeds budget") + })?; + let payload_len = usize::try_from(payload_bytes) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "segment budget is too large"))?; + let mut file = File::open(path)?; + file.seek(io::SeekFrom::End(-i64::try_from(payload_bytes).map_err( + |_| io::Error::new(io::ErrorKind::InvalidInput, "segment budget is too large"), + )?))?; + let mut tail = vec![0; payload_len]; + file.read_exact(&mut tail)?; + let tail = valid_utf8_tail(&tail); + let mut replacement_bytes = Vec::with_capacity(SEGMENT_TRUNCATION_MARKER.len() + tail.len()); + replacement_bytes.extend_from_slice(SEGMENT_TRUNCATION_MARKER.as_bytes()); + replacement_bytes.extend_from_slice(tail); + durable_replace(path, &replacement_bytes, fault, replacement) +} + +fn valid_utf8_tail(mut bytes: &[u8]) -> &[u8] { + loop { + match std::str::from_utf8(bytes) { + Ok(_) => return bytes, + Err(error) => match error.error_len() { + Some(invalid_bytes) => { + bytes = &bytes[error.valid_up_to().saturating_add(invalid_bytes)..]; + } + None => return &bytes[..error.valid_up_to()], + }, + } + } +} + +fn artifact_path(path: &Path, suffix: &str) -> PathBuf { + let mut name = path + .file_name() + .map_or_else(|| "gateway.log".into(), std::ffi::OsStr::to_os_string); + name.push(suffix); + path.with_file_name(name) +} + +fn remove_file_if_present(path: &Path) -> io::Result<()> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +fn sync_parent(path: &Path, fault: &mut FaultInjector) -> io::Result<()> { + fault.checkpoint("sync parent directory")?; + #[cfg(unix)] + { + File::open(path.parent().unwrap_or_else(|| Path::new(".")))?.sync_all() + } + #[cfg(not(unix))] + { + let _ = path; + Ok(()) + } +} + +fn write_durable_file(path: &Path, contents: &[u8], fault: &mut FaultInjector) -> io::Result<()> { + fault.checkpoint("create staged file")?; + let mut file = OpenOptions::new().write(true).create_new(true).open(path)?; + fault.checkpoint("write staged file")?; + file.write_all(contents)?; + fault.checkpoint("sync staged file")?; + file.sync_all() +} + +fn backup_file(source: &Path, backup: &Path, fault: &mut FaultInjector) -> io::Result<()> { + fault.checkpoint("create rollback copy")?; + if std::fs::hard_link(source, backup).is_ok() { + return Ok(()); + } + let building = artifact_path(backup, ".building"); + remove_file_if_present(&building)?; + let mut source = File::open(source)?; + let mut staged_backup = OpenOptions::new() + .write(true) + .create_new(true) + .open(&building)?; + io::copy(&mut source, &mut staged_backup)?; + staged_backup.sync_all()?; + drop(staged_backup); + std::fs::rename(building, backup) +} + +fn install_file( + target: &Path, + staged: Option<&Path>, + mode: ReplacementMode, + fault: &mut FaultInjector, +) -> io::Result<()> { + let Some(staged) = staged else { + if existing_file_len(target)?.is_some() { + fault.checkpoint("remove rotation target")?; + std::fs::remove_file(target)?; + } + return Ok(()); + }; + if matches!(mode, ReplacementMode::RemoveThenRename) && existing_file_len(target)?.is_some() { + fault.checkpoint("remove replacement target")?; + std::fs::remove_file(target)?; + } + fault.checkpoint("install replacement")?; + std::fs::rename(staged, target) +} + +fn recover_replacement(path: &Path) -> io::Result<()> { + let staged = artifact_path(path, ".compacting"); + let backup = artifact_path(path, ".compact-backup"); + remove_file_if_present(&artifact_path(&backup, ".building"))?; + if backup.exists() { + if path.exists() { + remove_file_if_present(&backup)?; + } else { + std::fs::rename(&backup, path)?; + } + } + remove_file_if_present(&staged) +} + +fn rollback_replacement(path: &Path) -> io::Result<()> { + let staged = artifact_path(path, ".compacting"); + let backup = artifact_path(path, ".compact-backup"); + remove_file_if_present(&artifact_path(&backup, ".building"))?; + if backup.exists() { + remove_file_if_present(path)?; + std::fs::rename(&backup, path)?; + } + remove_file_if_present(&staged)?; + sync_parent(path, &mut FaultInjector::default()) +} + +fn durable_replace( + path: &Path, + contents: &[u8], + fault: &mut FaultInjector, + mode: ReplacementMode, +) -> io::Result<()> { + recover_replacement(path)?; + let staged = artifact_path(path, ".compacting"); + let backup = artifact_path(path, ".compact-backup"); + let result = (|| { + write_durable_file(&staged, contents, fault)?; + backup_file(path, &backup, fault)?; + sync_parent(path, fault)?; + install_file(path, Some(&staged), mode, fault)?; + sync_parent(path, fault) + })(); + if let Err(error) = result { + if fault.is_simulated_crash() { + return Err(error); + } + return match rollback_replacement(path) { + Ok(()) => Err(error), + Err(rollback) => Err(io::Error::other(format!( + "{error}; replacement rollback failed: {rollback}" + ))), + }; + } + remove_file_if_present(&backup)?; + sync_parent(path, &mut FaultInjector::default()) +} + +fn rotation_committed_path(current: &Path) -> PathBuf { + artifact_path(current, ".rotation-committed") +} + +fn rotation_staged_path(current: &Path) -> PathBuf { + artifact_path(current, ".rotation-staged") +} + +fn rotation_prepared_path(current: &Path, old_mask: u8) -> PathBuf { + artifact_path(current, &format!(".rotation-prepared-{old_mask:02x}")) +} + +fn legacy_rotation_prepared_path(current: &Path) -> PathBuf { + artifact_path(current, ".rotation-prepared") +} + +fn rotation_targets(current: &Path, retained: &[PathBuf]) -> Vec { + std::iter::once(current.to_path_buf()) + .chain(retained.iter().cloned()) + .collect() +} + +fn rotation_old_mask(targets: &[PathBuf]) -> io::Result { + targets + .iter() + .enumerate() + .try_fold(0u8, |mask, (index, path)| { + Ok(if existing_file_len(path)?.is_some() { + mask | (1 << index) + } else { + mask + }) + }) +} + +fn read_legacy_rotation_mask(path: &Path) -> io::Result { + let bytes = std::fs::read(path)?; + if bytes.len() != 1 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid log rotation recovery marker", + )); + } + Ok(bytes[0]) +} + +fn find_rotation_prepared(current: &Path) -> io::Result> { + for old_mask in 0..64 { + let path = rotation_prepared_path(current, old_mask); + if path.exists() { + return Ok(Some((path, old_mask))); + } + } + let legacy = legacy_rotation_prepared_path(current); + if legacy.exists() { + return read_legacy_rotation_mask(&legacy).map(|old_mask| Some((legacy, old_mask))); + } + Ok(None) +} + +fn remove_rotation_file( + path: &Path, + operation: &'static str, + fault: &mut FaultInjector, +) -> io::Result<()> { + if existing_file_len(path)?.is_some() { + fault.checkpoint(operation)?; + std::fs::remove_file(path)?; + } + Ok(()) +} + +fn cleanup_rotation_with( + current: &Path, + retained: &[PathBuf], + fault: &mut FaultInjector, +) -> io::Result<()> { + for target in rotation_targets(current, retained) { + remove_rotation_file( + &artifact_path(&target, ".rotation-new"), + "cleanup staged rotation file", + fault, + )?; + let backup = artifact_path(&target, ".rotation-old"); + remove_rotation_file( + &artifact_path(&backup, ".building"), + "cleanup partial rollback file", + fault, + )?; + remove_rotation_file(&backup, "cleanup committed rollback file", fault)?; + } + remove_rotation_file( + &rotation_staged_path(current), + "cleanup staged rotation marker", + fault, + )?; + sync_parent(current, fault)?; + while let Some((prepared, _)) = find_rotation_prepared(current)? { + remove_rotation_file(&prepared, "cleanup prepared rotation marker", fault)?; + } + sync_parent(current, fault)?; + remove_rotation_file( + &rotation_committed_path(current), + "cleanup committed rotation marker", + fault, + )?; + sync_parent(current, fault) +} + +fn cleanup_rotation(current: &Path, retained: &[PathBuf]) -> io::Result<()> { + cleanup_rotation_with(current, retained, &mut FaultInjector::default()) +} + +fn rotation_source_for<'a>( + current: &'a Path, + retained: &'a [PathBuf], + destination_index: usize, +) -> &'a Path { + if destination_index == 0 { + current + } else { + &retained[destination_index - 1] + } +} + +fn rollback_rotation( + current: &Path, + retained: &[PathBuf], + prepared: &Path, + old_mask: u8, +) -> io::Result<()> { + let targets = rotation_targets(current, retained); + if rotation_staged_path(current).exists() { + for (index, destination) in retained.iter().enumerate().rev() { + let source = rotation_source_for(current, retained, index); + let backup = artifact_path(source, ".rotation-old"); + if old_mask & (1 << index) != 0 && !backup.exists() && destination.exists() { + std::fs::rename(destination, backup)?; + } + } + remove_file_if_present(current)?; + } + for (index, target) in targets.iter().enumerate().rev() { + let backup = artifact_path(target, ".rotation-old"); + remove_file_if_present(&artifact_path(&backup, ".building"))?; + if backup.exists() { + remove_file_if_present(target)?; + std::fs::rename(&backup, target)?; + } else if old_mask & (1 << index) == 0 { + remove_file_if_present(target)?; + } + } + for target in &targets { + remove_file_if_present(&artifact_path(target, ".rotation-new"))?; + } + remove_file_if_present(&rotation_committed_path(current))?; + remove_file_if_present(&rotation_staged_path(current))?; + sync_parent(current, &mut FaultInjector::default())?; + remove_file_if_present(prepared)?; + sync_parent(current, &mut FaultInjector::default()) +} + +fn recover_rotation(current: &Path, retained: &[PathBuf]) -> io::Result<()> { + if rotation_committed_path(current).exists() { + return cleanup_rotation(current, retained); + } + let Some((prepared, old_mask)) = find_rotation_prepared(current)? else { + return cleanup_rotation(current, retained); + }; + if old_mask >= 64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid log rotation recovery mask", + )); + } + rollback_rotation(current, retained, &prepared, old_mask) +} + +fn rotate_files( + current: &Path, + retained: &[PathBuf], + fault: &mut FaultInjector, + _mode: ReplacementMode, +) -> io::Result<()> { + recover_rotation(current, retained)?; + let targets = rotation_targets(current, retained); + let old_mask = rotation_old_mask(&targets)?; + let prepared = rotation_prepared_path(current, old_mask); + let result = (|| { + write_durable_file(&prepared, b"", fault)?; + sync_parent(current, fault)?; + for target in &targets { + if existing_file_len(target)?.is_some() { + fault.checkpoint("stage rotation source")?; + std::fs::rename(target, artifact_path(target, ".rotation-old"))?; + } + } + sync_parent(current, fault)?; + write_durable_file(&rotation_staged_path(current), b"", fault)?; + write_durable_file(&artifact_path(current, ".rotation-new"), b"", fault)?; + sync_parent(current, fault)?; + for (index, destination) in retained.iter().enumerate() { + let source = rotation_source_for(current, retained, index); + let staged = artifact_path(source, ".rotation-old"); + if staged.exists() { + fault.checkpoint("install rotated segment")?; + std::fs::rename(staged, destination)?; + } + } + let staged_current = artifact_path(current, ".rotation-new"); + fault.checkpoint("install fresh active segment")?; + std::fs::rename(staged_current, current)?; + sync_parent(current, fault)?; + write_durable_file(&rotation_committed_path(current), b"", fault)?; + fault.record_commit_marker(); + sync_parent(current, fault) + })(); + if let Err(error) = result { + if fault.is_simulated_crash() { + return Err(error); + } + return match rollback_rotation(current, retained, &prepared, old_mask) { + Ok(()) => Err(error), + Err(rollback) => Err(io::Error::other(format!( + "{error}; rotation rollback failed: {rollback}" + ))), + }; + } + cleanup_rotation_with(current, retained, fault) +} + +fn prune_oldest_for( + retained: &[PathBuf], + retained_bytes: &mut u64, + required_bytes: u64, + aggregate_bytes: u64, +) -> io::Result<()> { + while retained_bytes.saturating_add(required_bytes) > aggregate_bytes { + let mut oldest = None; + for path in retained.iter().rev() { + if let Some(bytes) = existing_file_len(path)? { + oldest = Some((path, bytes)); + break; + } + } + let Some((oldest, removed)) = oldest else { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "active log cannot fit the aggregate budget", + )); + }; + std::fs::remove_file(oldest)?; + *retained_bytes = retained_bytes.saturating_sub(removed); + } + Ok(()) +} + +#[derive(Debug)] +pub(crate) struct SegmentedFile { + current: PathBuf, + retained: Vec, + file: Option>, + current_bytes: u64, + retained_bytes: u64, + limits: RotationLimits, +} + +impl SegmentedFile { + fn write_line(&mut self, line: &str) -> io::Result<()> { + let line_bytes = line.len() as u64; + let marker_bytes = SEGMENT_TRUNCATION_MARKER.len() as u64; + if line_bytes.saturating_add(marker_bytes) > self.limits.segment { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "formatted record exceeds the segment terminal reserve", + )); + } + if self.current_bytes != 0 + && self + .current_bytes + .saturating_add(line_bytes) + .saturating_add(marker_bytes) + > self.limits.segment + { + self.ensure_aggregate_room(marker_bytes)?; + self.write_bytes(SEGMENT_TRUNCATION_MARKER.as_bytes())?; + self.flush()?; + self.rotate()?; + } + self.ensure_aggregate_room(line_bytes)?; + self.write_bytes(line.as_bytes()) + } + + fn write_bytes(&mut self, bytes: &[u8]) -> io::Result<()> { + self.file + .as_mut() + .ok_or_else(|| io::Error::other("active log segment is closed"))? + .write_all(bytes)?; + self.current_bytes = self.current_bytes.saturating_add(bytes.len() as u64); + Ok(()) + } + + fn ensure_aggregate_room(&mut self, additional_bytes: u64) -> io::Result<()> { + let required_retained_room = self.current_bytes.saturating_add(additional_bytes); + prune_oldest_for( + &self.retained, + &mut self.retained_bytes, + required_retained_room, + self.limits.aggregate, + ) + } + + fn rotate(&mut self) -> io::Result<()> { + self.flush()?; + self.file + .as_ref() + .ok_or_else(|| io::Error::other("active log segment is closed"))? + .get_ref() + .sync_all()?; + drop(self.file.take()); + let result = rotate_files( + &self.current, + &self.retained, + &mut FaultInjector::default(), + ReplacementMode::production(), + ); + self.file = OpenOptions::new() + .append(true) + .open(&self.current) + .map(BufWriter::new) + .map(Some)?; + result?; + self.retained_bytes = self.retained.iter().try_fold(0u64, |total, path| { + Ok::<_, io::Error>(total.saturating_add(existing_file_len(path)?.unwrap_or(0))) + })?; + self.current_bytes = 0; + Ok(()) + } + + fn flush(&mut self) -> io::Result<()> { + self.file + .as_mut() + .ok_or_else(|| io::Error::other("active log segment is closed"))? + .flush() + } +} + +/// The drain target: the rotated log file until a write fails, then +/// synchronous stderr so records still land somewhere. +#[derive(Debug)] +enum Sink { + Segmented(SegmentedFile), + /// A plain file used only to isolate fallback and latency behavior from + /// rotation in focused tests. + #[cfg(test)] + File(BufWriter), + Stderr, + /// The latency test's baseline: every write accepted, nothing done. + #[cfg(test)] + Null, + /// A controllable permanently stalled operation for shutdown tests. + #[cfg(test)] + Stalled { + point: StallPoint, + entered: std::sync::mpsc::SyncSender<()>, + release: Option>, + }, +} + +#[cfg(test)] +#[derive(Debug, Clone, Copy)] +pub(crate) enum StallPoint { + Write, + Flush, +} + +impl Sink { + fn write_line(&mut self, line: &str) { + match self { + Self::Segmented(file) => { + if let Err(error) = file.write_line(line) { + eprintln!( + "the log file rejected a write ({error}); logging falls back to stderr" + ); + *self = Self::Stderr; + self.write_line(line); + } + } + #[cfg(test)] + Self::File(file) => { + if let Err(error) = file.write_all(line.as_bytes()) { + eprintln!( + "the log file rejected a write ({error}); logging falls back to stderr" + ); + *self = Self::Stderr; + self.write_line(line); + } + } + Self::Stderr => { + let _ = io::stderr().lock().write_all(line.as_bytes()); + } + #[cfg(test)] + Self::Null => {} + #[cfg(test)] + Self::Stalled { + point: StallPoint::Write, + entered, + release, + } => { + if let Some(release) = release.take() { + let _ = entered.send(()); + let _ = release.recv(); + } + } + #[cfg(test)] + Self::Stalled { + point: StallPoint::Flush, + .. + } => {} + } + } + + fn flush(&mut self) { + match self { + Self::Segmented(file) => { + if let Err(error) = file.flush() { + eprintln!( + "the log file rejected a flush ({error}); logging falls back to stderr" + ); + *self = Self::Stderr; + } + } + #[cfg(test)] + Self::File(file) => { + if let Err(error) = file.flush() { + eprintln!( + "the log file rejected a flush ({error}); logging falls back to stderr" + ); + *self = Self::Stderr; + } + } + Self::Stderr => { + let _ = io::stderr().lock().flush(); + } + #[cfg(test)] + Self::Null => {} + #[cfg(test)] + Self::Stalled { + point: StallPoint::Flush, + entered, + release, + } => { + if let Some(release) = release.take() { + let _ = entered.send(()); + let _ = release.recv(); + } + } + #[cfg(test)] + Self::Stalled { + point: StallPoint::Write, + .. + } => {} + } + } + + /// Whether the sink has fallen back to stderr. Test seam for the + /// file-failure fallback contract. + #[cfg(test)] + fn is_stderr(&self) -> bool { + matches!(self, Self::Stderr) + } +} + +/// The worker owner: spawned by +/// [`LogRuntime::start`](crate::LogRuntime::start), then joined after a +/// healthy drain or detached after the shutdown budget expires. +#[derive(Debug)] +pub(crate) struct LogWorker { + handle: JoinHandle<()>, +} + +impl LogWorker { + /// Spawns the single worker thread. It blocks on the queue, swaps up to + /// a batch of records into local storage, and performs every write and + /// flush outside the mutex. + /// + /// # Errors + /// Returns the I/O failure from spawning the thread. + pub(crate) fn spawn(queue: Arc, file: SegmentedFile) -> io::Result { + Self::spawn_with_sink(queue, Sink::Segmented(file)) + } + + fn spawn_with_sink(queue: Arc, mut sink: Sink) -> io::Result { + let handle = std::thread::Builder::new() + .name("gateway-logging".to_string()) + .spawn(move || { + loop { + let batch = queue.take_batch(); + let records = batch.records.len(); + let had_summary = batch.summary.is_some(); + for record in &batch.records { + if queue.is_abandoned() { + return; + } + sink.write_line(&record.line); + } + if let Some(summary) = &batch.summary { + if queue.is_abandoned() { + return; + } + sink.write_line(summary); + } + if queue.is_abandoned() { + return; + } + sink.flush(); + if queue.is_abandoned() { + return; + } + queue.complete_batch(records, had_summary, batch.summary_affected); + if batch.done { + break; + } + } + })?; + Ok(Self { handle }) + } + + pub(crate) fn is_finished(&self) -> bool { + self.handle.is_finished() + } + + pub(crate) fn join(self) -> std::thread::Result<()> { + self.handle.join() + } + + #[cfg(test)] + pub(crate) fn spawn_stalled( + queue: Arc, + point: StallPoint, + ) -> io::Result<(Self, StalledSinkControl)> { + let (entered_tx, entered_rx) = std::sync::mpsc::sync_channel(1); + let (release_tx, release_rx) = std::sync::mpsc::sync_channel(1); + let worker = Self::spawn_with_sink( + queue, + Sink::Stalled { + point, + entered: entered_tx, + release: Some(release_rx), + }, + )?; + Ok(( + worker, + StalledSinkControl { + entered: entered_rx, + release: release_tx, + }, + )) + } +} + +#[cfg(test)] +pub(crate) struct StalledSinkControl { + entered: std::sync::mpsc::Receiver<()>, + release: std::sync::mpsc::SyncSender<()>, +} + +#[cfg(test)] +impl StalledSinkControl { + pub(crate) fn wait_until_stalled( + &self, + timeout: std::time::Duration, + ) -> Result<(), std::sync::mpsc::RecvTimeoutError> { + self.entered.recv_timeout(timeout) + } + + pub(crate) fn release(self) { + let _ = self.release.send(()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::queue::LogPriority; + + /// A file whose handle rejects writes, standing in for a disk + /// failure: opened read-only, every write and flush errors. + fn rejected_file(dir: &Path) -> File { + let path = dir.join("rejected.log"); + std::fs::write(&path, "").expect("seed the file"); + std::fs::OpenOptions::new() + .read(true) + .open(&path) + .expect("open read-only") + } + + #[test] + fn a_failed_file_write_falls_back_to_synchronous_stderr() { + let temp = TempStateDir::new("sink-write-fallback"); + let mut sink = Sink::File(BufWriter::new(rejected_file(&temp.0))); + assert!(!sink.is_stderr(), "the sink starts on the file"); + + // A record larger than the buffer bypasses it and reaches the + // rejecting handle immediately. + let big = "x".repeat(16 * 1024); + sink.write_line(&big); + assert!( + sink.is_stderr(), + "a rejected write switches the sink to stderr" + ); + sink.write_line("after the fallback\n"); + sink.flush(); + assert!( + sink.is_stderr(), + "the fallback keeps accepting records instead of failing" + ); + } + + #[test] + fn a_failed_file_flush_falls_back_to_synchronous_stderr() { + let temp = TempStateDir::new("sink-flush-fallback"); + let mut sink = Sink::File(BufWriter::new(rejected_file(&temp.0))); + + // A small record sits in the buffer, so the write succeeds and + // the flush is what the handle rejects. + sink.write_line("buffered record\n"); + assert!(!sink.is_stderr(), "a buffered write has not failed yet"); + sink.flush(); + assert!( + sink.is_stderr(), + "a rejected flush switches the sink to stderr" + ); + } + + /// Enqueues `records` lines and drains them through `sink` with the + /// real worker's batch loop, returning the p95 enqueue-to-write + /// latency. The enqueue instant is stamped before the record enters + /// the queue, so the queue's sequence number indexes the stamps. + fn measure_p95_enqueue_to_write(sink: Sink, records: usize) -> std::time::Duration { + use std::sync::Mutex; + use std::time::Instant; + + let queue = Arc::new(LogQueue::new()); + let stamps = Arc::new(Mutex::new(Vec::::with_capacity(records))); + let worker = { + let queue = Arc::clone(&queue); + let stamps = Arc::clone(&stamps); + std::thread::spawn(move || { + let mut sink = sink; + let mut latencies = Vec::with_capacity(records); + loop { + let batch = queue.take_batch(); + for record in &batch.records { + sink.write_line(&record.line); + let written = Instant::now(); + let index = usize::try_from(record.sequence).expect("sequence fits"); + let enqueued = stamps.lock().expect("stamps mutex")[index]; + latencies.push(written - enqueued); + } + if let Some(summary) = &batch.summary { + sink.write_line(summary); + } + sink.flush(); + if batch.done { + break; + } + } + latencies + }) + }; + for index in 0..records { + stamps.lock().expect("stamps mutex").push(Instant::now()); + queue.enqueue( + LogPriority::Info, + Box::from(format!( + "latency probe {index}: a record of roughly the size a formatted event has\n" + )), + ); + } + queue.close(); + let mut latencies = worker.join().expect("the worker joins"); + assert_eq!( + latencies.len(), + records, + "every enqueued record was written" + ); + let p95 = records * 95 / 100; + latencies.select_nth_unstable(p95); + latencies[p95] + } + + #[test] + #[ignore = "release-mode latency budget: run `cargo test -p gateway-logging --release -- --ignored`"] + fn production_logging_stays_within_latency_budget() { + use std::time::Duration; + + const RECORDS: usize = 20_000; + + let baseline = measure_p95_enqueue_to_write(Sink::Null, RECORDS); + let temp = TempStateDir::new("latency"); + let (_path, file) = open_log_file(&temp.0).expect("open the production segmented sink"); + let file_sink = measure_p95_enqueue_to_write(Sink::Segmented(file), RECORDS); + + // The budget: less than 2% over the null-sink baseline, or 1 ms, + // whichever is larger. + let budget = (baseline / 50).max(Duration::from_millis(1)); + println!("p95 enqueue-to-write: null sink {baseline:?}, file sink {file_sink:?}"); + println!("budget: {budget:?} (2% of baseline or 1 ms, whichever is larger)"); + assert!( + file_sink <= baseline + budget, + "the file sink's p95 {file_sink:?} exceeds the baseline {baseline:?} by more than {budget:?}" + ); + } + + struct TempStateDir(PathBuf); + + impl TempStateDir { + fn new(test: &str) -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "gateway-logging-{test}-{}-{id}", + std::process::id() + )); + std::fs::create_dir_all(&dir).expect("create the temp state dir"); + Self(dir) + } + } + + impl Drop for TempStateDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn the_log_rotation_retains_five_previous_runs() { + let temp = TempStateDir::new("rotation"); + std::fs::create_dir_all(temp.0.join("logs")).expect("logs dir"); + std::fs::write(temp.0.join("logs/gateway.log"), "first run").expect("seed log"); + + let (path, file) = open_log_file(&temp.0).expect("first rotation opens"); + drop(file); + assert_eq!(path, temp.0.join("logs/gateway.log")); + assert_eq!( + std::fs::read_to_string(temp.0.join("logs/gateway.log.1")).expect("rotated log"), + "first run", + "the previous run's log rotates to .1" + ); + assert_eq!( + std::fs::read_to_string(&path).expect("fresh log"), + "", + "the new run starts on a fresh file" + ); + + // Five more runs fill the retained chain: after six rotations the + // first run has shifted to .5 and every slot holds its run. + for run in 2..=6u32 { + std::fs::write(&path, format!("run {run}")).expect("write the run's log"); + let (_path, file) = open_log_file(&temp.0).expect("rotation opens"); + drop(file); + } + for run in 1..=5u32 { + assert_eq!( + std::fs::read_to_string(temp.0.join(format!("logs/gateway.log.{run}"))) + .expect("retained log"), + format!("run {}", 7 - run), + ".{run} holds the run {n} log", + n = 7 - run + ); + } + assert!( + !temp.0.join("logs/gateway.log.6").exists(), + "retention stops at five previous runs" + ); + } + + #[test] + fn the_sixth_previous_run_drops_off_the_retained_chain() { + let temp = TempStateDir::new("rotation-drop"); + std::fs::create_dir_all(temp.0.join("logs")).expect("logs dir"); + + // Seven runs: the two oldest must leave the chain entirely once + // more than five previous runs exist. + for run in 1..=7u32 { + std::fs::write(temp.0.join("logs/gateway.log"), format!("run {run}")) + .expect("write the run's log"); + let (_path, file) = open_log_file(&temp.0).expect("rotation opens"); + drop(file); + } + let retained: Vec = (1..=5u32) + .map(|run| { + std::fs::read_to_string(temp.0.join(format!("logs/gateway.log.{run}"))) + .expect("retained log") + }) + .collect(); + assert_eq!( + retained, + vec!["run 7", "run 6", "run 5", "run 4", "run 3"], + "the chain holds exactly the five newest previous runs" + ); + assert!( + !retained.iter().any(|contents| contents == "run 1"), + "the sixth previous run is deleted, not retained" + ); + } + + fn total_log_bytes(state_dir: &Path) -> u64 { + let config = LogConfig::new(state_dir); + std::iter::once(config.log_path()) + .chain(config.retained_log_paths()) + .map(|path| path.metadata().map_or(0, |metadata| metadata.len())) + .sum() + } + + fn total_directory_file_bytes(directory: &Path) -> u64 { + std::fs::read_dir(directory) + .expect("read log directory") + .map(|entry| { + entry + .expect("read log entry") + .metadata() + .expect("read log metadata") + .len() + }) + .sum() + } + + fn crashing_fault(fail_at: usize) -> FaultInjector { + FaultInjector { + fail_at: Some(fail_at), + simulated_crash: true, + ..FaultInjector::default() + } + } + + fn assert_injected_checkpoint(fault: &FaultInjector, fail_at: usize, transaction: &str) { + assert_eq!( + fault.calls, fail_at, + "only the selected filesystem checkpoint interrupts {transaction}" + ); + assert!( + fault.failed_operation.is_some(), + "an injected {transaction} crash records its filesystem operation" + ); + } + + fn observed_rotation_commit(current: &Path, fault: &FaultInjector) -> bool { + rotation_committed_path(current).exists() || fault.commit_marker_written + } + + fn interrupted_final_commit_cleanup(current: &Path, fault: &FaultInjector) -> bool { + fault.failed_operation == Some("sync parent directory") + && fault.commit_marker_written + && !rotation_committed_path(current).exists() + && find_rotation_prepared(current) + .expect("inspect prepared rotation marker") + .is_none() + } + + #[test] + fn restart_compaction_recovers_every_injected_filesystem_failure() { + let original = "old-prefix-".repeat(20) + "terminal diagnostic\n"; + let mut forced_replacement_gap = false; + let mut completed = false; + for (failures, fail_at) in (1..=32).enumerate() { + let temp = TempStateDir::new("compaction-crash"); + let path = temp.0.join("gateway.log"); + std::fs::write(&path, &original).expect("seed oversized source"); + let mut fault = crashing_fault(fail_at); + let result = compact_oversized_segment_with( + &path, + 64, + &mut fault, + ReplacementMode::RemoveThenRename, + ); + if result.is_ok() { + completed = true; + assert_eq!( + failures, fault.calls, + "the loop injected every staged replacement checkpoint independently" + ); + break; + } + assert_injected_checkpoint(&fault, fail_at, "replacement"); + if fault.failed_operation == Some("install replacement") { + forced_replacement_gap = true; + assert!( + !path.exists() && artifact_path(&path, ".compact-backup").exists(), + "the forced Windows replacement gap retains the original rollback copy" + ); + } + recover_replacement(&path).expect("restart recovers compaction"); + let recovered = std::fs::read_to_string(&path).expect("one complete copy survives"); + assert!( + recovered == original + || (recovered.starts_with(SEGMENT_TRUNCATION_MARKER) + && recovered.ends_with("terminal diagnostic\n") + && recovered.len() <= 64), + "recovery keeps either the source or the complete durable replacement" + ); + } + assert!( + forced_replacement_gap, + "fault injection reaches the destructive Windows rename boundary" + ); + assert!( + completed, + "the fault loop reaches the first non-failing run" + ); + } + + #[test] + fn live_rotation_recovers_every_injected_filesystem_failure() { + let mut forced_staging_gap = false; + let mut forced_commit_cleanup_gap = false; + let mut completed = false; + for (failures, fail_at) in (1..=128).enumerate() { + let temp = TempStateDir::new("rotation-crash"); + let logs = temp.0.join("logs"); + std::fs::create_dir_all(&logs).expect("create logs"); + let current = logs.join("gateway.log"); + let retained = LogConfig::new(&temp.0).retained_log_paths(); + std::fs::write(¤t, "active\n").expect("seed active"); + for (index, path) in retained.iter().enumerate() { + std::fs::write(path, format!("old-{}\n", index + 1)).expect("seed retained"); + } + let old: Vec> = std::iter::once(¤t) + .chain(retained.iter()) + .map(|path| std::fs::read(path).expect("snapshot old chain")) + .collect(); + let disk_budget = old.iter().map(Vec::len).sum::() as u64; + let mut fault = crashing_fault(fail_at); + let result = rotate_files( + ¤t, + &retained, + &mut fault, + ReplacementMode::RemoveThenRename, + ); + if result.is_ok() { + completed = true; + assert_eq!( + failures, fault.calls, + "the loop injected every rotation checkpoint independently" + ); + assert!( + total_directory_file_bytes(&logs) <= disk_budget, + "a completed rotation stays inside the original aggregate bytes" + ); + assert_eq!(std::fs::read(¤t).expect("new active"), b""); + for (index, path) in retained.iter().enumerate() { + assert_eq!( + std::fs::read(path).expect("new retained"), + old[index], + "the committed chain shifts each prior segment exactly once" + ); + } + break; + } + assert_injected_checkpoint(&fault, fail_at, "rotation"); + assert!( + total_directory_file_bytes(&logs) <= disk_budget, + "transaction artifacts stay inside the aggregate budget at checkpoint {fail_at}" + ); + // Cleanup removes the marker before its final parent sync. The + // per-transaction state preserves that commit decision if that + // exact sync is the injected crash boundary. + let committed = observed_rotation_commit(¤t, &fault); + forced_commit_cleanup_gap |= interrupted_final_commit_cleanup(¤t, &fault); + if fault.failed_operation == Some("stage rotation source") + && rotation_targets(¤t, &retained).iter().any(|target| { + !target.exists() && artifact_path(target, ".rotation-old").exists() + }) + { + forced_staging_gap = true; + } + recover_rotation(¤t, &retained).expect("restart recovers rotation"); + assert!( + total_directory_file_bytes(&logs) <= disk_budget, + "recovery stays inside the same aggregate disk budget" + ); + if committed { + assert_eq!(std::fs::read(¤t).expect("committed active"), b""); + for (index, path) in retained.iter().enumerate() { + assert_eq!( + std::fs::read(path).expect("committed retained"), + old[index], + "a durable commit marker keeps the complete new chain" + ); + } + } else { + for (index, path) in std::iter::once(¤t).chain(retained.iter()).enumerate() { + assert_eq!( + std::fs::read(path).expect("rolled back chain"), + old[index], + "an uncommitted rotation restores every prior diagnostic name" + ); + } + } + } + assert!( + forced_staging_gap, + "fault injection reaches an in-place staging boundary with the source preserved" + ); + assert!( + forced_commit_cleanup_gap, + "fault injection reaches the final sync after commit-marker cleanup" + ); + assert!( + completed, + "the fault loop reaches the first non-failing run" + ); + } + + #[test] + fn committed_sparse_rotation_survives_every_cleanup_crash_boundary() { + let mut completed = false; + for (failures, fail_at) in (1..=32).enumerate() { + let temp = TempStateDir::new("sparse-cleanup-crash"); + let logs = temp.0.join("logs"); + std::fs::create_dir_all(&logs).expect("create logs"); + let config = LogConfig::new(&temp.0); + let current = config.log_path(); + let retained = config.retained_log_paths(); + std::fs::write(¤t, "").expect("seed fresh active"); + std::fs::write(&retained[0], "active\n").expect("seed shifted active"); + std::fs::write(&retained[2], "old-2\n").expect("seed sparse shifted segment"); + std::fs::write(&retained[4], "old-4\n").expect("seed sparse oldest destination"); + std::fs::write(artifact_path(&retained[4], ".rotation-old"), "old-5\n") + .expect("seed pruned rollback segment"); + std::fs::write(artifact_path(¤t, ".rotation-new"), "") + .expect("seed stale empty stage"); + let old_mask = 1 | (1 << 2) | (1 << 4) | (1 << 5); + std::fs::write(rotation_prepared_path(¤t, old_mask), "") + .expect("seed prepared marker"); + std::fs::write(rotation_staged_path(¤t), "").expect("seed staged marker"); + std::fs::write(rotation_committed_path(¤t), "").expect("seed commit marker"); + let disk_budget = total_directory_file_bytes(&logs); + + let mut fault = crashing_fault(fail_at); + let result = cleanup_rotation_with(¤t, &retained, &mut fault); + if result.is_ok() { + completed = true; + assert_eq!( + failures, fault.calls, + "the loop injected every sparse cleanup checkpoint independently" + ); + } else { + assert_injected_checkpoint(&fault, fail_at, "cleanup"); + assert!( + total_directory_file_bytes(&logs) <= disk_budget, + "interrupted cleanup never duplicates segment bytes" + ); + recover_rotation(¤t, &retained).expect("restart completes committed cleanup"); + } + + assert_eq!(std::fs::read(¤t).expect("active survives"), b""); + assert_eq!( + std::fs::read(&retained[0]).expect("newest survives"), + b"active\n" + ); + assert!(!retained[1].exists(), "the sparse .2 remains absent"); + assert_eq!( + std::fs::read(&retained[2]).expect("sparse .3 survives"), + b"old-2\n" + ); + assert!(!retained[3].exists(), "the sparse .4 remains absent"); + assert_eq!( + std::fs::read(&retained[4]).expect("sparse .5 survives"), + b"old-4\n" + ); + assert!( + total_directory_file_bytes(&logs) < disk_budget, + "the committed oldest rollback segment is pruned after recovery" + ); + if completed { + break; + } + } + assert!( + completed, + "the fault loop reaches the first non-failing sparse cleanup" + ); + } + + #[test] + fn rotation_reserves_the_marker_and_preserves_the_terminal_record() { + let temp = TempStateDir::new("segment-terminal"); + let limits = RotationLimits { + segment: 64, + aggregate: 128, + terminal_record: 24, + }; + let (path, sink) = open_log_file_with_limits(&temp.0, limits).expect("open segmented log"); + let queue = Arc::new(LogQueue::new()); + let worker = + LogWorker::spawn(Arc::clone(&queue), sink).expect("spawn the production worker"); + queue.enqueue(LogPriority::Info, Box::from("ordinary-record-000\n")); + queue.enqueue(LogPriority::Info, Box::from("ordinary-record-001\n")); + queue.enqueue(LogPriority::Info, Box::from("gateway exiting\n")); + queue.close(); + worker.join().expect("worker drains segmented sink"); + + let retained = + std::fs::read_to_string(temp.0.join("logs/gateway.log.1")).expect("retained segment"); + assert!( + retained.ends_with(SEGMENT_TRUNCATION_MARKER), + "the full segment ends with the reserved marker" + ); + assert!( + retained.len() as u64 <= limits.segment, + "the retained segment obeys its fixed-size budget" + ); + assert_eq!( + std::fs::read_to_string(path).expect("active segment"), + "gateway exiting\n", + "the terminal record moves whole to the active segment" + ); + assert!( + total_log_bytes(&temp.0) <= limits.aggregate, + "active and retained bytes stay inside one aggregate budget" + ); + } + + #[test] + fn byte_boundaries_rotate_a_full_numbered_chain_without_splitting_utf8() { + let temp = TempStateDir::new("segment-byte-boundaries"); + let logs = temp.0.join("logs"); + std::fs::create_dir_all(&logs).expect("create logs"); + let config = LogConfig::new(&temp.0); + let current = config.log_path(); + let retained = config.retained_log_paths(); + File::create(¤t).expect("create active"); + for (index, path) in retained.iter().enumerate() { + std::fs::write(path, format!("old-{}\n", index + 1)).expect("seed full chain"); + } + let retained_bytes = retained + .iter() + .map(|path| path.metadata().expect("retained metadata").len()) + .sum(); + let limits = RotationLimits { + segment: 48, + aggregate: 48 * 6, + terminal_record: 16, + }; + let mut sink = SegmentedFile { + current: current.clone(), + retained: retained.clone(), + file: Some(BufWriter::new( + OpenOptions::new() + .append(true) + .open(¤t) + .expect("open active"), + )), + current_bytes: 0, + retained_bytes, + limits, + }; + let multibyte = "😀aaaaaaaaaaaaaa\n"; + let exact_boundary = "bbbbbbbbbbbbbbb\n"; + let maximum_terminal = "ccccccccccccccc\n"; + assert_eq!(multibyte.len(), 19); + assert_eq!(exact_boundary.len(), 16); + assert_eq!( + u64::try_from(maximum_terminal.len()).expect("record length fits u64"), + limits.terminal_record + ); + + sink.write_line(multibyte).expect("write multibyte record"); + sink.write_line(exact_boundary) + .expect("exact byte boundary stays in the active segment"); + sink.flush().expect("flush exact boundary"); + assert_eq!( + std::fs::read_to_string(¤t).expect("read exact active"), + format!("{multibyte}{exact_boundary}"), + "equality with the reserved marker does not rotate" + ); + + sink.write_line(maximum_terminal) + .expect("one byte over rotates before the maximum terminal record"); + sink.flush().expect("flush terminal"); + let newest = + std::fs::read_to_string(&retained[0]).expect("newest retained remains valid UTF-8"); + assert_eq!( + newest, + format!("{multibyte}{exact_boundary}{SEGMENT_TRUNCATION_MARKER}") + ); + assert_eq!(newest.len() as u64, limits.segment); + assert_eq!( + std::fs::read_to_string(¤t).expect("active terminal"), + maximum_terminal + ); + for (index, path) in retained.iter().enumerate().skip(1) { + assert_eq!( + std::fs::read_to_string(path).expect("shifted retained"), + format!("old-{index}\n"), + "the complete numbered chain shifts oldest-first" + ); + } + assert!( + !std::fs::read_to_string(&retained[retained.len() - 1]) + .expect("oldest retained") + .contains("old-5"), + "the prior oldest segment is pruned only after its replacement is durable" + ); + assert!( + total_log_bytes(&temp.0) <= limits.aggregate, + "all named segments remain within the aggregate byte budget" + ); + } + + #[test] + fn restart_caps_legacy_segments_and_prunes_oldest_before_admission() { + let temp = TempStateDir::new("segment-restart"); + let logs = temp.0.join("logs"); + std::fs::create_dir_all(&logs).expect("logs dir"); + let terminal = "gateway exiting after a fatal error\n"; + std::fs::write( + logs.join("gateway.log"), + format!("{}{}", "😀".repeat(40), terminal), + ) + .expect("seed oversized active log"); + std::fs::write(logs.join("gateway.log.1"), "newer-retained".repeat(4)) + .expect("seed newer retained log"); + std::fs::write(logs.join("gateway.log.2"), "oldest-retained".repeat(4)) + .expect("seed oldest retained log"); + let limits = RotationLimits { + segment: 64, + aggregate: 80, + terminal_record: 40, + }; + + let (_path, mut first) = + open_log_file_with_limits(&temp.0, limits).expect("normalize first restart"); + first + .write_line("first restart\n") + .expect("write after first restart"); + first.flush().expect("flush first restart"); + let normalized = + std::fs::read_to_string(logs.join("gateway.log.1")).expect("normalized legacy segment"); + assert!( + normalized.starts_with(SEGMENT_TRUNCATION_MARKER), + "an oversized legacy segment records the omitted prefix" + ); + assert!( + normalized.ends_with(terminal), + "tail compaction reserves enough room for the prior terminal record" + ); + drop(first); + let (_path, mut second) = + open_log_file_with_limits(&temp.0, limits).expect("normalize second restart"); + second + .write_line("second restart\n") + .expect("write after second restart"); + second.flush().expect("flush second restart"); + + let config = LogConfig::new(&temp.0); + for path in std::iter::once(config.log_path()).chain(config.retained_log_paths()) { + let bytes = path.metadata().map_or(0, |metadata| metadata.len()); + assert!( + bytes <= limits.segment, + "{} exceeded the segment budget with {bytes} bytes", + path.display() + ); + } + assert!( + total_log_bytes(&temp.0) <= limits.aggregate, + "restart normalization and later writes preserve the aggregate budget" + ); + assert!( + !logs.join("gateway.log.2").exists(), + "oldest segments are pruned before newer bytes are admitted" + ); + let retained = + std::fs::read_to_string(logs.join("gateway.log.1")).expect("newest retained segment"); + assert!( + retained.contains("first restart"), + "the current numbered diagnostic name retains the newest prior segment" + ); + } +} diff --git a/crates/gateway-logging/src/writer.rs b/crates/gateway-logging/src/writer.rs new file mode 100644 index 00000000..9146da5c --- /dev/null +++ b/crates/gateway-logging/src/writer.rs @@ -0,0 +1,839 @@ +//! The `MakeWriter` adapter between the binary's fmt layer and the queue. + +use std::fmt; +use std::io; +use std::sync::Arc; + +use tracing::Metadata; +use tracing::field::{Field, Visit}; +use tracing_subscriber::field::{RecordFields, VisitOutput}; +use tracing_subscriber::fmt::MakeWriter; +use tracing_subscriber::fmt::format::{DefaultVisitor, FormatFields, Writer}; + +use crate::config::LOG_LIMITS; +use crate::queue::{FormatStatus, LogPriority, LogQueue}; +use crate::redact::{REDACTED, is_sensitive_field, redact_line_bounded}; + +/// The suffix replacing omitted formatter bytes. It includes the record's +/// terminal newline because truncation may discard the formatter's own. +const TRUNCATION_MARKER: &str = " [truncated]\n"; + +/// A cloneable factory that hands the fmt layer per-event writers feeding +/// the queue. +/// +/// Priority comes only from the event's tracing metadata; the formatted +/// text passes through the privacy redaction before it can reach the +/// queue. Use one clone as the file layer's field formatter so classified +/// values are replaced without invoking their formatting implementation. +/// Obtained from +/// [`LogRuntime::writer`](crate::LogRuntime::writer). +/// +/// # Examples +/// ``` +/// # let dir = std::env::temp_dir().join(concat!("gateway-logging-doc-writer-", env!("CARGO_PKG_VERSION"))); +/// let runtime = gateway_logging::LogRuntime::start(gateway_logging::LogConfig::new(&dir))?; +/// let writer = runtime.writer(); +/// let _subscriber = tracing_subscriber::fmt() +/// .fmt_fields(writer.clone()) +/// .with_writer(writer) +/// .finish(); +/// runtime.shutdown()?; +/// # std::fs::remove_dir_all(&dir).ok(); +/// # Ok::<(), gateway_logging::LogError>(()) +/// ``` +#[derive(Debug, Clone)] +pub struct LogWriter { + queue: Arc, +} + +impl LogWriter { + pub(crate) fn new(queue: Arc) -> Self { + Self { queue } + } +} + +impl<'a> MakeWriter<'a> for LogWriter { + type Writer = LogEventWriter; + + fn make_writer(&'a self) -> LogEventWriter { + LogEventWriter::new(Arc::clone(&self.queue), LogPriority::Info) + } + + fn make_writer_for(&'a self, meta: &Metadata<'_>) -> LogEventWriter { + LogEventWriter::new( + Arc::clone(&self.queue), + LogPriority::from_level(*meta.level()), + ) + } +} + +impl<'writer> FormatFields<'writer> for LogWriter { + fn format_fields(&self, writer: Writer<'writer>, fields: R) -> fmt::Result + where + R: RecordFields, + { + let mut visitor = RedactingVisitor { + inner: DefaultVisitor::new(writer, true), + }; + fields.record(&mut visitor); + visitor.inner.finish() + } +} + +struct RedactingVisitor<'writer> { + inner: DefaultVisitor<'writer>, +} + +impl RedactingVisitor<'_> { + fn redact(&mut self, field: &Field) -> bool { + if is_sensitive_field(field.name()) { + self.inner.record_str(field, REDACTED); + true + } else { + false + } + } +} + +impl Visit for RedactingVisitor<'_> { + fn record_f64(&mut self, field: &Field, value: f64) { + if !self.redact(field) { + self.inner.record_f64(field, value); + } + } + + fn record_i64(&mut self, field: &Field, value: i64) { + if !self.redact(field) { + self.inner.record_i64(field, value); + } + } + + fn record_u64(&mut self, field: &Field, value: u64) { + if !self.redact(field) { + self.inner.record_u64(field, value); + } + } + + fn record_i128(&mut self, field: &Field, value: i128) { + if !self.redact(field) { + self.inner.record_i128(field, value); + } + } + + fn record_u128(&mut self, field: &Field, value: u128) { + if !self.redact(field) { + self.inner.record_u128(field, value); + } + } + + fn record_bool(&mut self, field: &Field, value: bool) { + if !self.redact(field) { + self.inner.record_bool(field, value); + } + } + + fn record_str(&mut self, field: &Field, value: &str) { + if !self.redact(field) { + self.inner.record_str(field, value); + } + } + + fn record_bytes(&mut self, field: &Field, value: &[u8]) { + if !self.redact(field) { + self.inner.record_bytes(field, value); + } + } + + fn record_error(&mut self, field: &Field, value: &(dyn std::error::Error + 'static)) { + if !self.redact(field) { + self.inner.record_error(field, value); + } + } + + fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { + if !self.redact(field) { + self.inner.record_debug(field, value); + } + } +} + +/// Buffers every `Write` call for one formatted event and enqueues the +/// owned line on drop, so a partial formatter write never becomes a +/// partial queue record. +/// +/// Not public API: `MakeWriter::Writer` cannot name a private type, so the +/// compiler forces this onto the public surface; it is `#[doc(hidden)]` +/// and constructible only through [`LogWriter`]. +#[doc(hidden)] +#[derive(Debug)] +pub struct LogEventWriter { + queue: Arc, + priority: LogPriority, + buffer: BoundedBytes, + truncated: bool, + utf8: Utf8Validator, +} + +impl LogEventWriter { + fn new(queue: Arc, priority: LogPriority) -> Self { + Self { + queue, + priority, + buffer: BoundedBytes::new(LOG_LIMITS.max_formatted_record_bytes), + truncated: false, + utf8: Utf8Validator::default(), + } + } +} + +impl io::Write for LogEventWriter { + fn write(&mut self, buffer: &[u8]) -> io::Result { + self.utf8.push(buffer); + let retained = self.buffer.extend_from_slice(buffer); + self.truncated |= retained < buffer.len(); + Ok(buffer.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl Drop for LogEventWriter { + fn drop(&mut self) { + if self.buffer.is_empty() { + return; + } + if !self.utf8.is_complete() { + self.queue.reject_formatted(); + return; + } + let Some(line) = finish_formatter_text(&mut self.buffer, self.truncated) else { + self.queue.reject_formatted(); + return; + }; + // The privacy chokepoint: every record crosses here, so the + // well-shaped secrets are masked before they can reach the queue. + let Some((line, truncated)) = + redact_line_bounded(line, LOG_LIMITS.max_formatted_record_bytes) + .finish(TRUNCATION_MARKER, self.truncated) + else { + self.queue.reject_formatted(); + return; + }; + let status = if truncated { + FormatStatus::Truncated + } else { + FormatStatus::Complete + }; + self.queue.enqueue_formatted(self.priority, line, status); + } +} + +/// Fixed-capacity formatter storage whose allocation cannot grow. +#[derive(Debug)] +struct BoundedBytes { + storage: Box<[u8]>, + len: usize, +} + +impl BoundedBytes { + fn new(capacity: usize) -> Self { + #[cfg(test)] + crate::allocation_tracking::record(capacity); + Self { + storage: vec![0; capacity].into_boxed_slice(), + len: 0, + } + } + + fn capacity(&self) -> usize { + self.storage.len() + } + + fn is_empty(&self) -> bool { + self.len == 0 + } + + fn as_slice(&self) -> &[u8] { + &self.storage[..self.len] + } + + fn truncate(&mut self, len: usize) { + self.len = self.len.min(len); + } + + fn extend_from_slice(&mut self, bytes: &[u8]) -> usize { + let retained = bytes.len().min(self.capacity().saturating_sub(self.len)); + self.storage[self.len..self.len + retained].copy_from_slice(&bytes[..retained]); + self.len += retained; + retained + } +} + +/// Allocation-free incremental validation covering retained and discarded +/// formatter bytes across arbitrary `Write` boundaries. +#[derive(Debug, Default)] +struct Utf8Validator { + tail: [u8; 3], + tail_len: usize, + invalid: bool, +} + +impl Utf8Validator { + fn push(&mut self, mut bytes: &[u8]) { + if self.invalid { + return; + } + if self.tail_len != 0 { + let mut combined = [0; 4]; + combined[..self.tail_len].copy_from_slice(&self.tail[..self.tail_len]); + let taken = bytes.len().min(4 - self.tail_len); + combined[self.tail_len..self.tail_len + taken].copy_from_slice(&bytes[..taken]); + let combined_len = self.tail_len + taken; + match std::str::from_utf8(&combined[..combined_len]) { + Ok(_) => self.tail_len = 0, + Err(error) if error.error_len().is_some() => { + self.invalid = true; + return; + } + Err(error) => { + let tail = &combined[error.valid_up_to()..combined_len]; + self.tail[..tail.len()].copy_from_slice(tail); + self.tail_len = tail.len(); + return; + } + } + bytes = &bytes[taken..]; + } + if let Err(error) = std::str::from_utf8(bytes) { + if error.error_len().is_some() { + self.invalid = true; + } else { + let tail = &bytes[error.valid_up_to()..]; + self.tail[..tail.len()].copy_from_slice(tail); + self.tail_len = tail.len(); + } + } + } + + fn is_complete(&self) -> bool { + !self.invalid && self.tail_len == 0 + } +} + +/// Borrows valid text from the bounded byte buffer without repairing invalid +/// formatter bytes. A retained prefix ending inside a code point rewinds to +/// its valid boundary; validation of the original bytes happened on write. +fn finish_formatter_text(buffer: &mut BoundedBytes, truncated: bool) -> Option<&str> { + if truncated { + let payload_limit = LOG_LIMITS + .max_formatted_record_bytes + .saturating_sub(TRUNCATION_MARKER.len()); + buffer.truncate(payload_limit); + if let Err(error) = std::str::from_utf8(buffer.as_slice()) { + if error.error_len().is_some() { + return None; + } + buffer.truncate(error.valid_up_to()); + } + } + std::str::from_utf8(buffer.as_slice()).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::LOG_LIMITS; + use std::fmt; + use std::io::Write as _; + use std::sync::atomic::{AtomicBool, Ordering}; + + struct ProtectedValue<'a> { + value: &'a str, + formatted: &'a AtomicBool, + } + + impl fmt::Debug for ProtectedValue<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.formatted.store(true, Ordering::SeqCst); + formatter.write_str(self.value) + } + } + + #[test] + fn an_oversized_event_never_allocates_or_enqueues_above_the_record_limit() { + let queue = Arc::new(LogQueue::new()); + let writer = LogWriter::new(Arc::clone(&queue)); + let oversized = vec![b'x'; LOG_LIMITS.max_formatted_record_bytes + 1]; + { + let mut event = MakeWriter::make_writer(&writer); + for chunk in oversized.chunks(997) { + event.write_all(chunk).expect("accept formatter chunk"); + } + assert!( + event.buffer.capacity() == LOG_LIMITS.max_formatted_record_bytes, + "formatter storage has one fixed capacity equal to the record budget" + ); + } + queue.close(); + + let batch = queue.take_batch(); + assert_eq!(batch.records.len(), 1, "the bounded prefix is retained"); + assert!( + batch.records[0].line.len() <= LOG_LIMITS.max_formatted_record_bytes, + "the queued record obeys the byte budget" + ); + assert!( + batch.records[0].line.ends_with(TRUNCATION_MARKER), + "the retained prefix explicitly marks omitted text" + ); + assert!( + batch + .summary + .as_deref() + .is_some_and(|summary| summary.contains("truncated=1")), + "truncation enters the queue's observable loss episode" + ); + } + + #[test] + fn expanding_redaction_never_requests_an_allocation_above_the_record_limit() { + const UNIT: &str = "api_key=a prompt=b path=c payload=d "; + + let queue = Arc::new(LogQueue::new()); + let writer = LogWriter::new(Arc::clone(&queue)); + let expansion_heavy = UNIT.repeat(LOG_LIMITS.max_formatted_record_bytes / UNIT.len()); + + let allocations = crate::allocation_tracking::AllocationTracker::start(); + { + let mut event = MakeWriter::make_writer(&writer); + event + .write_all(expansion_heavy.as_bytes()) + .expect("accept expansion-heavy formatter bytes"); + } + let largest_request = allocations.finish(); + queue.close(); + + assert!( + largest_request <= LOG_LIMITS.max_formatted_record_bytes, + "largest per-record allocation request {largest_request} exceeds {}", + LOG_LIMITS.max_formatted_record_bytes + ); + let batch = queue.take_batch(); + assert_eq!(batch.records.len(), 1); + assert!( + batch.records[0].line.ends_with(TRUNCATION_MARKER), + "bounded expansion is explicitly marked" + ); + for cleartext in ["api_key=a", "prompt=b", "path=c", "payload=d"] { + assert!( + !batch.records[0].line.contains(cleartext), + "retained assignments are redacted before enqueue" + ); + } + } + + #[test] + fn truncation_rewinds_to_a_valid_multibyte_boundary() { + let queue = Arc::new(LogQueue::new()); + let writer = LogWriter::new(Arc::clone(&queue)); + let payload_budget = LOG_LIMITS.max_formatted_record_bytes - TRUNCATION_MARKER.len(); + let mut oversized = vec![b'x'; payload_budget - 1]; + oversized.extend_from_slice("😀".as_bytes()); + oversized.extend(std::iter::repeat_n(b'y', TRUNCATION_MARKER.len())); + { + let mut event = MakeWriter::make_writer(&writer); + event + .write_all(&oversized) + .expect("accept the formatted event"); + } + queue.close(); + + let batch = queue.take_batch(); + let line = &batch.records[0].line; + assert!( + line.len() <= LOG_LIMITS.max_formatted_record_bytes, + "the multibyte record obeys the byte budget" + ); + assert!( + line.ends_with(TRUNCATION_MARKER), + "the valid prefix carries the truncation marker" + ); + assert!( + !line.contains('\u{fffd}'), + "truncation never replaces a split code point" + ); + } + + #[test] + fn invalid_formatter_bytes_are_rejected_with_observable_loss() { + let queue = Arc::new(LogQueue::new()); + let writer = LogWriter::new(Arc::clone(&queue)); + { + let mut event = MakeWriter::make_writer(&writer); + event + .write_all(&[b'v', 0xff, b'\n']) + .expect("accept formatter bytes"); + } + queue.close(); + + let batch = queue.take_batch(); + assert!( + batch.records.is_empty(), + "invalid UTF-8 is rejected instead of repaired" + ); + assert!( + batch + .summary + .as_deref() + .is_some_and(|summary| summary.contains("rejected=1")), + "rejection enters the queue's observable loss episode" + ); + } + + #[test] + fn invalid_utf8_after_the_retained_prefix_rejects_the_whole_record() { + let queue = Arc::new(LogQueue::new()); + let writer = LogWriter::new(Arc::clone(&queue)); + let retained = vec![b'v'; LOG_LIMITS.max_formatted_record_bytes]; + { + let mut event = MakeWriter::make_writer(&writer); + event + .write_all(&retained) + .expect("accept the retained valid prefix"); + event + .write_all(&[b'x', 0xff]) + .expect("accept discarded formatter bytes"); + } + queue.close(); + + let batch = queue.take_batch(); + assert!( + batch.records.is_empty(), + "invalid UTF-8 hidden beyond the retained prefix rejects the record" + ); + assert_eq!( + batch.summary.as_deref(), + Some( + "log pressure affected 1 record(s): dropped=1, debug=0, trace=0, info=0, truncated=0, rejected=1\n" + ) + ); + } + + #[test] + fn truncation_rejection_and_eviction_share_exactly_one_loss_summary() { + use crate::queue::CAPACITY; + + let queue = Arc::new(LogQueue::new()); + for index in 0..CAPACITY { + queue.enqueue( + LogPriority::Debug, + format!("debug-{index}\n").into_boxed_str(), + ); + } + let writer = LogWriter::new(Arc::clone(&queue)); + { + let mut truncated = MakeWriter::make_writer(&writer); + truncated + .write_all(&vec![b't'; LOG_LIMITS.max_formatted_record_bytes + 1]) + .expect("accept oversized formatter bytes"); + } + { + let mut rejected = MakeWriter::make_writer(&writer); + rejected + .write_all(&[b'i', 0xff]) + .expect("accept invalid formatter bytes"); + } + queue.close(); + + let mut summaries = Vec::new(); + let mut saw_truncated_record = false; + loop { + let batch = queue.take_batch(); + saw_truncated_record |= batch + .records + .iter() + .any(|record| record.line.ends_with(TRUNCATION_MARKER)); + if let Some(summary) = batch.summary { + summaries.push(summary); + } + if batch.done { + break; + } + } + + assert!(saw_truncated_record, "the truncated record was admitted"); + assert_eq!( + summaries.iter().map(Box::as_ref).collect::>(), + [ + "log pressure affected 3 record(s): dropped=2, debug=1, trace=0, info=0, truncated=1, rejected=1\n" + ], + "eviction, truncation, and rejection close as one exactly-accounted episode" + ); + } + + #[test] + fn drop_enqueues_one_record_for_many_partial_writes() { + let queue = Arc::new(LogQueue::new()); + let writer = LogWriter::new(Arc::clone(&queue)); + { + let mut event = MakeWriter::make_writer(&writer); + event.write_all(b"partial ").expect("buffered write"); + event.write_all(b"writes\n").expect("buffered write"); + event.flush().expect("flush is a no-op"); + // Nothing is enqueued until the writer drops. + assert!( + queue.is_empty(), + "a partial write is never a partial record" + ); + } + queue.close(); + let batch = queue.take_batch(); + assert_eq!( + batch.records.len(), + 1, + "one formatted event is exactly one queue record" + ); + assert_eq!(&*batch.records[0].line, "partial writes\n"); + assert_eq!(batch.records[0].priority, LogPriority::Info); + } + + #[test] + fn priority_comes_from_the_event_metadata() { + let queue = Arc::new(LogQueue::new()); + let writer = LogWriter::new(Arc::clone(&queue)); + let subscriber = tracing_subscriber::fmt() + .with_ansi(false) + .with_max_level(tracing::Level::TRACE) + .with_writer(writer) + .finish(); + tracing::subscriber::with_default(subscriber, || { + tracing::error!("an error"); + tracing::warn!("a warning"); + tracing::info!("an info"); + tracing::debug!("a debug"); + tracing::trace!("a trace"); + }); + queue.close(); + let batch = queue.take_batch(); + let priorities: Vec = + batch.records.iter().map(|record| record.priority).collect(); + assert_eq!( + priorities, + [ + LogPriority::Error, + LogPriority::Warn, + LogPriority::Info, + LogPriority::Debug, + LogPriority::Trace, + ], + "make_writer_for derives the lane from tracing metadata alone" + ); + assert!( + batch.records[0].line.contains("an error"), + "the record carries the formatted event: {}", + batch.records[0].line + ); + } + + #[test] + fn a_secret_in_an_event_is_masked_before_it_reaches_the_queue() { + let queue = Arc::new(LogQueue::new()); + let writer = LogWriter::new(Arc::clone(&queue)); + let subscriber = tracing_subscriber::fmt() + .with_ansi(false) + .with_max_level(tracing::Level::TRACE) + .with_writer(writer) + .finish(); + tracing::subscriber::with_default(subscriber, || { + tracing::warn!( + authorization = "Bearer tok_secret_9f8c", + "upstream rejected the key" + ); + tracing::info!("sending Cookie: session=abc123 to the upstream"); + }); + queue.close(); + let batch = queue.take_batch(); + assert_eq!(batch.records.len(), 2); + for record in &batch.records { + assert!( + !record.line.contains("tok_secret_9f8c"), + "a bearer token in event fields never reaches a record: {}", + record.line + ); + assert!( + !record.line.contains("abc123"), + "a cookie value in a message never reaches a record: {}", + record.line + ); + } + assert!( + batch.records[0].line.contains("[redacted]"), + "the mask marks where the secret stood: {}", + batch.records[0].line + ); + } + + #[test] + fn classified_fields_are_redacted_without_formatting_their_values() { + const SECRETS: [&str; 7] = [ + "basic-secret", + "cookie-secret", + "url-secret", + "prompt-secret", + "path-secret", + "payload-secret", + "typed-secret", + ]; + + let queue = Arc::new(LogQueue::new()); + let writer = LogWriter::new(Arc::clone(&queue)); + let formatted = AtomicBool::new(false); + let protected = ProtectedValue { + value: SECRETS[6], + formatted: &formatted, + }; + let subscriber = tracing_subscriber::fmt() + .with_ansi(false) + .with_max_level(tracing::Level::TRACE) + .fmt_fields(writer.clone()) + .with_writer(writer) + .finish(); + + tracing::subscriber::with_default(subscriber, || { + tracing::warn!( + authorization = "Basic basic-secret", + request_cookie = "session=cookie-secret", + upstream_url = "https://user:url-secret@example.test/v1", + system_prompt = "prompt-secret", + config_path = "C:\\private\\path-secret\\model.gguf", + request_payload = "{\"token\":\"payload-secret\"}", + secret = ?protected, + ordinary = 7_u64, + "classified field matrix" + ); + }); + queue.close(); + + let batch = queue.take_batch(); + assert_eq!(batch.records.len(), 1); + let line = &batch.records[0].line; + for secret in SECRETS { + assert!( + !line.contains(secret), + "classified value reached the queue: {line}" + ); + } + assert!( + !formatted.load(Ordering::SeqCst), + "a secret-typed debug value was formatted before redaction" + ); + assert!( + line.contains("ordinary=7"), + "unclassified structured fields keep their formatting: {line}" + ); + assert!( + line.contains("classified field matrix"), + "unstructured wording remains intact: {line}" + ); + } + + #[test] + fn composite_sensitive_aliases_never_invoke_adversarial_formatters() { + const SECRETS: [&str; 3] = [ + "authorization-alias-secret", + "cookie-alias-secret", + "token-alias-secret", + ]; + let formatted = std::array::from_fn::<_, 3, _>(|_| AtomicBool::new(false)); + let authorization = ProtectedValue { + value: SECRETS[0], + formatted: &formatted[0], + }; + let cookie = ProtectedValue { + value: SECRETS[1], + formatted: &formatted[1], + }; + let token = ProtectedValue { + value: SECRETS[2], + formatted: &formatted[2], + }; + let queue = Arc::new(LogQueue::new()); + let writer = LogWriter::new(Arc::clone(&queue)); + let subscriber = tracing_subscriber::fmt() + .with_ansi(false) + .fmt_fields(writer.clone()) + .with_writer(writer) + .finish(); + + tracing::subscriber::with_default(subscriber, || { + tracing::warn!( + authorization_header = ?authorization, + cookie_header = ?cookie, + token_value = ?token, + "composite alias matrix" + ); + }); + queue.close(); + + let batch = queue.take_batch(); + assert_eq!(batch.records.len(), 1); + for (index, secret) in SECRETS.iter().enumerate() { + assert!( + !formatted[index].load(Ordering::SeqCst), + "the formatter for alias {index} was invoked" + ); + assert!( + !batch.records[0].line.contains(secret), + "a composite alias reached the queue: {}", + batch.records[0].line + ); + } + } + + #[test] + fn unclassified_fields_keep_default_formatting_byte_for_byte() { + let default_queue = Arc::new(LogQueue::new()); + let default_writer = LogWriter::new(Arc::clone(&default_queue)); + let default_subscriber = tracing_subscriber::fmt() + .without_time() + .with_ansi(false) + .with_writer(default_writer) + .finish(); + tracing::subscriber::with_default(default_subscriber, || { + tracing::info!( + target: "format-regression", + count = 7_u64, + label = "ordinary", + "unchanged wording" + ); + }); + default_queue.close(); + let default_line = default_queue.take_batch().records.remove(0).line; + + let redacting_queue = Arc::new(LogQueue::new()); + let redacting_writer = LogWriter::new(Arc::clone(&redacting_queue)); + let redacting_subscriber = tracing_subscriber::fmt() + .without_time() + .with_ansi(false) + .fmt_fields(redacting_writer.clone()) + .with_writer(redacting_writer) + .finish(); + tracing::subscriber::with_default(redacting_subscriber, || { + tracing::info!( + target: "format-regression", + count = 7_u64, + label = "ordinary", + "unchanged wording" + ); + }); + redacting_queue.close(); + let redacting_line = redacting_queue.take_batch().records.remove(0).line; + + assert_eq!( + redacting_line, default_line, + "the redacting visitor delegates ordinary values to DefaultVisitor" + ); + } +} diff --git a/crates/gateway-logging/tests/it/main.rs b/crates/gateway-logging/tests/it/main.rs new file mode 100644 index 00000000..c7f36d37 --- /dev/null +++ b/crates/gateway-logging/tests/it/main.rs @@ -0,0 +1,66 @@ +//! The dependency boundary: `gateway-logging` links only the standard +//! library, `tracing`, and `tracing-subscriber`, so the log pipeline can +//! never grow a dependency on the gateway, sidecar state, or STT types. +//! This test reads the crate's own manifest and fails when any other +//! dependency appears. + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +/// The allowlist the crate's AGENTS.md grants. +const ALLOWED: [&str; 2] = ["tracing", "tracing-subscriber"]; + +#[test] +fn the_manifest_declares_only_the_tracing_dependencies() { + let manifest_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"); + let manifest = fs::read_to_string(&manifest_path).expect("the crate manifest must be readable"); + + let mut section = String::new(); + let mut declared = BTreeSet::new(); + let mut forbidden_tables = Vec::new(); + for raw_line in manifest.lines() { + let line = raw_line.trim(); + if line.starts_with('[') && line.ends_with(']') { + section = line.trim_matches(['[', ']']).trim().to_string(); + // No build, dev, or target-specific dependency tables: the + // boundary covers every way a crate can enter the build. + if section != "dependencies" + && (section.ends_with("dependencies") || section.starts_with("target.")) + { + forbidden_tables.push(section.clone()); + } + // A `[dependencies.]` sub-table declares the dependency + // `` without a `key = value` line under + // `[dependencies]`, so count it against the same allowlist. + if let Some(rest) = section.strip_prefix("dependencies.") + && let Some(name) = rest.split('.').next() + { + declared.insert(name.to_string()); + } + continue; + } + if section == "dependencies" + && !line.starts_with('#') + && let Some((key, _)) = line.split_once('=') + { + // `tracing.workspace = true` names the `tracing` crate: the + // dotted suffix is workspace inheritance, not part of the + // dependency name. + let key = key.trim(); + let name = key.split_once('.').map_or(key, |(name, _)| name); + declared.insert(name.to_string()); + } + } + + let expected: BTreeSet<&str> = ALLOWED.into_iter().collect(); + let declared: BTreeSet<&str> = declared.iter().map(String::as_str).collect(); + assert_eq!( + declared, expected, + "gateway-logging may depend only on tracing and tracing-subscriber" + ); + assert!( + forbidden_tables.is_empty(), + "gateway-logging declares no build, dev, or target-specific dependencies: {forbidden_tables:?}" + ); +} diff --git a/crates/gateway-stt-backend-whisper/AGENTS.md b/crates/gateway-stt-backend-whisper/AGENTS.md new file mode 100644 index 00000000..0b28c18b --- /dev/null +++ b/crates/gateway-stt-backend-whisper/AGENTS.md @@ -0,0 +1,6 @@ +# gateway-stt-backend-whisper + +This crate owns safe Whisper backend construction and decode policy: model loading, prompt fitting, parameters, progress, and translation into engine errors. + +- Unsafe code, ABI layouts, raw pointers, and C symbols stay in `gateway-whisper-ffi`. +- Host configuration types and HTTP, WebSocket, UI, session, and take state stay outside this crate. diff --git a/crates/gateway-stt-backend-whisper/Cargo.toml b/crates/gateway-stt-backend-whisper/Cargo.toml new file mode 100644 index 00000000..2a02d34a --- /dev/null +++ b/crates/gateway-stt-backend-whisper/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "gateway-stt-backend-whisper" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +description = "Safe Whisper decoder backend for the PromptForge STT engine" + +[dependencies] +gateway-stt-engine.workspace = true +gateway-whisper-ffi.workspace = true +shared-progress.workspace = true +tracing.workspace = true + +[dev-dependencies] +gateway-stt-engine = { workspace = true, features = ["test-fixtures"] } +hound.workspace = true +tempfile.workspace = true +tokio.workspace = true + +[features] +test-fixtures = ["gateway-stt-engine/test-fixtures"] + +[lints] +workspace = true diff --git a/crates/gateway-stt-backend-whisper/module-ceilings.toml b/crates/gateway-stt-backend-whisper/module-ceilings.toml new file mode 100644 index 00000000..a3bb116e --- /dev/null +++ b/crates/gateway-stt-backend-whisper/module-ceilings.toml @@ -0,0 +1,11 @@ +# Exact source and public-root counts for the safe Whisper backend. +# Physical lines include comments and blanks. Every recorded ceiling equals +# the measured file size, so any size change updates this manifest explicitly. + +public_root_count = 2 + +[modules] +"config.rs" = 32 +"lib.rs" = 8 +"model.rs" = 293 +"prompt.rs" = 237 diff --git a/crates/gateway-stt-backend-whisper/src/config.rs b/crates/gateway-stt-backend-whisper/src/config.rs new file mode 100644 index 00000000..04b7c8f1 --- /dev/null +++ b/crates/gateway-stt-backend-whisper/src/config.rs @@ -0,0 +1,32 @@ +//! Safe Whisper backend construction values. + +use std::path::PathBuf; + +use shared_progress::ProgressHandle; + +/// Provisioned Whisper runtime, model paths, and optional load progress. +#[derive(Debug, Clone)] +pub struct WhisperConfig { + pub(crate) library: PathBuf, + pub(crate) interim_model: PathBuf, + pub(crate) final_model: Option, + pub(crate) progress: Option, +} + +impl WhisperConfig { + /// Creates a backend configuration from provisioned artifact paths. + #[must_use] + pub fn new( + library: PathBuf, + interim_model: PathBuf, + final_model: Option, + progress: Option, + ) -> Self { + Self { + library, + interim_model, + final_model, + progress, + } + } +} diff --git a/crates/gateway-stt-backend-whisper/src/lib.rs b/crates/gateway-stt-backend-whisper/src/lib.rs new file mode 100644 index 00000000..e099a4bc --- /dev/null +++ b/crates/gateway-stt-backend-whisper/src/lib.rs @@ -0,0 +1,8 @@ +//! Safe Whisper backend for the backend-neutral STT engine. + +mod config; +mod model; +mod prompt; + +pub use config::WhisperConfig; +pub use model::WhisperModelFactory; diff --git a/crates/gateway-stt-backend-whisper/src/model.rs b/crates/gateway-stt-backend-whisper/src/model.rs new file mode 100644 index 00000000..14383164 --- /dev/null +++ b/crates/gateway-stt-backend-whisper/src/model.rs @@ -0,0 +1,293 @@ +//! Whisper model factory, decoder, progress, and error translation. + +use std::io::Read; +use std::path::Path; + +use gateway_stt_engine::{ + DecodeMode, DecodeRequest, Decoder, EnginePolicy, ModelFactory, TranscribeError, +}; +use gateway_whisper_ffi::{ + FullParams, SamplingStrategy, WhisperContext, WhisperLibrary, WhisperState, +}; +use shared_progress::ProgressHandle; + +use crate::WhisperConfig; +use crate::prompt::{GLOSSARY_TOKEN_BUDGET, final_prompt, fit_glossary, sanitize_prompt}; + +const MAX_PROMPT_TOKENS: usize = 224; +const PREWARM_CHUNK: usize = 4 * 1024 * 1024; + +/// Factory for safe Whisper decoders backed by provisioned runtime artifacts. +#[derive(Debug)] +pub struct WhisperModelFactory { + config: WhisperConfig, + library: WhisperLibrary, + gpu_available: bool, +} + +impl WhisperModelFactory { + /// Loads the runtime library and validates the configured model paths. + /// + /// Model contexts are created later on their owning engine workers. + /// + /// # Errors + /// Returns a backend or model construction failure translated into the + /// engine's backend-neutral error type. + pub fn new(config: WhisperConfig) -> Result { + require_model_file(&config.interim_model)?; + if let Some(final_model) = &config.final_model { + require_model_file(final_model)?; + } + let library = + WhisperLibrary::load(&config.library).map_err(TranscribeError::initialize_backend)?; + library.set_log_callback(); + let gpu_available = library.gpu_available().unwrap_or_else(|error| { + tracing::warn!(%error, "could not inspect whisper GPU support"); + false + }); + Ok(Self { + config, + library, + gpu_available, + }) + } + + /// Whether the loaded runtime reports hardware acceleration. + #[must_use] + pub fn gpu_available(&self) -> bool { + self.gpu_available + } +} + +impl ModelFactory for WhisperModelFactory { + fn create(&self, mode: DecodeMode) -> Result>, TranscribeError> { + let (path, progress_name) = match mode { + DecodeMode::Interim => (&self.config.interim_model, "interim"), + DecodeMode::Final => { + let Some(path) = &self.config.final_model else { + return Ok(None); + }; + (path, "final") + } + }; + let progress = self + .config + .progress + .as_ref() + .map(|handle| handle.child(progress_name, 1.0)); + WhisperDecoder::load(&self.library, path, progress.as_ref()) + .map(|decoder| Some(Box::new(decoder) as Box)) + } +} + +#[derive(Debug)] +struct WhisperDecoder { + context: WhisperContext, + state: WhisperState, +} + +impl WhisperDecoder { + fn load( + library: &WhisperLibrary, + path: &Path, + progress: Option<&ProgressHandle>, + ) -> Result { + let prewarm_leaf = progress.map(|handle| handle.child("prewarm", 1.0)); + prewarm(path, prewarm_leaf.as_ref())?; + let init_leaf = progress.map(|handle| handle.child("init", 1.0)); + let context = + WhisperContext::new(library, path).map_err(|source| load_model_error(path, source))?; + let state = context + .create_state() + .map_err(|source| load_model_error(path, source))?; + if let Some(leaf) = &init_leaf { + leaf.complete(); + } + Ok(Self { context, state }) + } +} + +impl Decoder for WhisperDecoder { + fn decode(&mut self, request: DecodeRequest) -> Result { + let final_pass = request.mode() == DecodeMode::Final; + if final_pass + && (request.samples().len() < EnginePolicy::MIN_WINDOW_SAMPLES + || EnginePolicy::is_silence(request.samples())) + { + return Ok(String::new()); + } + let glossary_budget = if final_pass { + GLOSSARY_TOKEN_BUDGET + } else { + MAX_PROMPT_TOKENS + }; + let glossary = fit_glossary(&self.context, request.guidance(), glossary_budget); + let prompt = if final_pass { + Some(final_prompt( + &self.context, + glossary.as_deref(), + request.finalized(), + )) + } else { + glossary + }; + transcribe_blocking( + &mut self.state, + request.samples(), + prompt.as_deref(), + !final_pass, + ) + } +} + +fn require_model_file(path: &Path) -> Result<(), TranscribeError> { + let metadata = std::fs::metadata(path).map_err(|source| load_model_error(path, source))?; + if metadata.is_file() { + Ok(()) + } else { + Err(load_model_error( + path, + std::io::Error::other("model path is not a file"), + )) + } +} + +fn load_model_error( + path: &Path, + source: impl std::error::Error + Send + Sync + 'static, +) -> TranscribeError { + TranscribeError::load_model(path.to_path_buf(), source) +} + +fn inference_error(source: impl std::error::Error + Send + Sync + 'static) -> TranscribeError { + TranscribeError::inference(source) +} + +fn prewarm(path: &Path, progress: Option<&ProgressHandle>) -> Result<(), TranscribeError> { + let total = std::fs::metadata(path) + .map_err(|source| load_model_error(path, source))? + .len(); + let mut file = std::fs::File::open(path).map_err(|source| load_model_error(path, source))?; + let mut buffer = vec![0u8; PREWARM_CHUNK]; + let mut done = 0u64; + loop { + let read = file + .read(&mut buffer) + .map_err(|source| load_model_error(path, source))?; + if read == 0 { + break; + } + done += read as u64; + if let Some(leaf) = progress { + leaf.set_units(done, total); + } + } + if let Some(leaf) = progress { + leaf.complete(); + } + Ok(()) +} + +fn transcribe_blocking( + state: &mut WhisperState, + samples: &[f32], + prompt: Option<&str>, + single_segment: bool, +) -> Result { + let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 }); + params.set_language(Some("en")).map_err(inference_error)?; + params.set_translate(false); + params.set_no_context(true); + params.set_single_segment(single_segment); + params.set_no_timestamps(true); + params.set_print_special(false); + params.set_print_progress(false); + params.set_print_realtime(false); + params.set_print_timestamps(false); + params.set_suppress_blank(true); + params.set_suppress_nst(true); + if let Some(prompt) = prompt { + let prompt = sanitize_prompt(prompt); + if !prompt.is_empty() { + params + .set_initial_prompt(&prompt) + .map_err(inference_error)?; + } + } + state.full(¶ms, samples).map_err(inference_error)?; + let mut text = String::new(); + for segment in 0..state.segment_count() { + text.push_str(&state.segment_text(segment).map_err(inference_error)?); + } + Ok(text.trim().to_owned()) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use shared_progress::ProgressHub; + + use super::*; + + #[test] + fn prewarm_of_a_plain_file_completes_progress() { + let directory = tempfile::tempdir().expect("temporary model directory"); + let path = directory.path().join("model.bin"); + std::fs::write(&path, vec![0u8; 1024]).expect("fake model writes"); + let hub = Arc::new(ProgressHub::new()); + let tree = hub.operation(); + let leaf = tree.register("prewarm", 1.0); + prewarm(&path, Some(&leaf)).expect("prewarm reads the model"); + assert!((leaf.fraction() - 1.0).abs() < f64::EPSILON); + } + + #[test] + fn prewarm_failure_is_a_model_error_naming_the_path() { + let path = Path::new("definitely-missing-prewarm-model.bin"); + let error = prewarm(path, None).expect_err("missing model must fail"); + assert!(matches!(error, TranscribeError::LoadModel { .. })); + assert!( + error + .to_string() + .contains("definitely-missing-prewarm-model.bin") + ); + } + + #[test] + fn missing_interim_model_fails_before_library_loading() { + let config = WhisperConfig::new( + "unused-library".into(), + "definitely-missing-interim-model.bin".into(), + None, + None, + ); + let error = WhisperModelFactory::new(config).expect_err("missing model must fail"); + assert!(matches!(error, TranscribeError::LoadModel { .. })); + assert!( + error + .to_string() + .contains("definitely-missing-interim-model.bin") + ); + } + + #[test] + fn missing_final_model_fails_before_library_loading() { + let directory = tempfile::tempdir().expect("temporary model directory"); + let interim = directory.path().join("interim.bin"); + std::fs::write(&interim, b"model").expect("interim fixture writes"); + let config = WhisperConfig::new( + "unused-library".into(), + interim, + Some("definitely-missing-final-model.bin".into()), + None, + ); + let error = WhisperModelFactory::new(config).expect_err("missing model must fail"); + assert!(matches!(error, TranscribeError::LoadModel { .. })); + assert!( + error + .to_string() + .contains("definitely-missing-final-model.bin") + ); + } +} diff --git a/crates/gateway-stt-backend-whisper/src/prompt.rs b/crates/gateway-stt-backend-whisper/src/prompt.rs new file mode 100644 index 00000000..8ac96fca --- /dev/null +++ b/crates/gateway-stt-backend-whisper/src/prompt.rs @@ -0,0 +1,237 @@ +//! Whisper conditioning prompt construction and fitting. + +use gateway_whisper_ffi::WhisperContext; + +const MAX_PROMPT_CHARS: usize = 800; +const MAX_PROMPT_TOKENS: usize = 224; +pub(crate) const GLOSSARY_TOKEN_BUDGET: usize = MAX_PROMPT_TOKENS / 2; + +fn tail_chars(text: &str, max: usize) -> &str { + let mut start = text.len().saturating_sub(max); + while !text.is_char_boundary(start) { + start += 1; + } + &text[start..] +} + +pub(crate) fn sanitize_prompt(prompt: &str) -> String { + let cleaned: String = prompt + .chars() + .filter(|&character| character != '\0') + .collect(); + tail_chars(&cleaned, MAX_PROMPT_CHARS).to_owned() +} + +fn glossary_prompt(vocabulary: &[String]) -> Option { + let terms: Vec = vocabulary + .iter() + .map(|term| { + term.trim() + .chars() + .filter(|&character| character != '\0') + .collect::() + }) + .filter(|term| !term.is_empty()) + .collect(); + if terms.is_empty() { + return None; + } + Some(format!("Glossary: {}.", terms.join(", "))) +} + +fn token_count(context: &WhisperContext, text: &str) -> usize { + context + .tokenize(text, text.len().max(1)) + .map_or(usize::MAX, |tokens| tokens.len()) +} + +pub(crate) fn fit_glossary( + context: &WhisperContext, + vocabulary: &[String], + budget: usize, +) -> Option { + let mut len = vocabulary.len(); + let mut fitted = glossary_prompt(vocabulary)?; + while fitted.len() > MAX_PROMPT_CHARS || token_count(context, &fitted) > budget { + len -= 1; + if len == 0 { + tracing::warn!("no voice vocabulary term fits the prompt budget"); + return None; + } + fitted = glossary_prompt(&vocabulary[..len])?; + } + if len < vocabulary.len() { + tracing::warn!( + kept = len, + dropped = vocabulary.len() - len, + "voice vocabulary truncated to fit whisper's prompt budget" + ); + } + Some(fitted) +} + +pub(crate) fn final_prompt( + context: &WhisperContext, + glossary: Option<&str>, + transcript: &str, +) -> String { + let Some(glossary) = glossary else { + return sanitize_prompt(transcript); + }; + let cleaned: String = transcript + .chars() + .filter(|&character| character != '\0') + .collect(); + let char_budget = MAX_PROMPT_CHARS.saturating_sub(glossary.len() + 1); + let mut tail = tail_chars(&cleaned, char_budget).trim_start(); + loop { + if tail.is_empty() { + return glossary.to_owned(); + } + let combined = format!("{glossary} {tail}"); + if token_count(context, &combined) <= MAX_PROMPT_TOKENS { + return combined; + } + tail = match tail.find(char::is_whitespace) { + Some(index) => tail[index..].trim_start(), + None => "", + }; + } +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use gateway_stt_engine::test_fixtures::native::require_fixture; + use gateway_whisper_ffi::WhisperLibrary; + + use super::*; + + static NATIVE_TEST: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn native_fixture_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") + } + + #[test] + fn native_prompt_test_keeps_its_backend_fixture_root() { + assert_eq!( + native_fixture_root(), + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") + ); + } + + fn require_context() -> WhisperContext { + let library_path = require_fixture( + "PROMPTFORGE_WHISPER_LIBRARY", + &native_fixture_root(), + "whisper.dll", + ); + let model_path = require_fixture( + "PROMPTFORGE_WHISPER_MODEL", + &native_fixture_root(), + "ggml-tiny.en.bin", + ); + let library = WhisperLibrary::load(&library_path).expect("packaged whisper runtime loads"); + WhisperContext::new(&library, &model_path).expect("whisper fixture model loads") + } + + #[test] + fn sanitize_prompt_strips_nulls_and_caps_length() { + assert_eq!(sanitize_prompt("hello"), "hello"); + assert_eq!(sanitize_prompt("a\0b"), "ab"); + assert_eq!( + sanitize_prompt(&"x".repeat(MAX_PROMPT_CHARS + 100)).len(), + MAX_PROMPT_CHARS + ); + let multibyte = sanitize_prompt(&"é".repeat(MAX_PROMPT_CHARS + 10)); + assert!(multibyte.len() <= MAX_PROMPT_CHARS); + assert!(multibyte.chars().all(|character| character == 'é')); + } + + #[test] + fn glossary_prompt_rejects_empty_terms() { + assert_eq!(glossary_prompt(&[]), None); + assert_eq!(glossary_prompt(&[String::new()]), None); + assert_eq!(glossary_prompt(&[" \0 ".to_owned()]), None); + } + + #[test] + fn glossary_prompt_cleans_and_formats_terms() { + let vocabulary: Vec = [" tokio ", "ax\0um", ""].map(str::to_owned).into(); + assert_eq!( + glossary_prompt(&vocabulary), + Some("Glossary: tokio, axum.".to_owned()) + ); + } + + #[test] + #[ignore = "requires packaged whisper and model fixtures"] + fn fit_glossary_enforces_character_and_token_boundaries() { + let _guard = NATIVE_TEST + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let context = require_context(); + + let exact_char_limit = vec!["a".repeat(MAX_PROMPT_CHARS - "Glossary: .".len())]; + let exact = fit_glossary(&context, &exact_char_limit, usize::MAX) + .expect("a glossary exactly at the character limit fits"); + assert_eq!(exact.len(), MAX_PROMPT_CHARS); + let over_char_limit = vec!["a".repeat(MAX_PROMPT_CHARS - "Glossary: .".len() + 1)]; + assert_eq!( + fit_glossary(&context, &over_char_limit, usize::MAX), + None, + "a glossary one character over the limit is rejected" + ); + + let vocabulary: Vec = ["MCP", "GGUF", "Lua"].map(str::to_owned).into(); + let full = glossary_prompt(&vocabulary).expect("the vocabulary is usable"); + let exact_token_budget = token_count(&context, &full); + assert_eq!( + fit_glossary(&context, &vocabulary, exact_token_budget), + Some(full.clone()), + "a glossary exactly at the token limit fits" + ); + let trimmed = fit_glossary(&context, &vocabulary, exact_token_budget - 1) + .expect("the leading glossary terms still fit"); + assert_ne!(trimmed, full, "one token less forces truncation"); + assert!(token_count(&context, &trimmed) < exact_token_budget); + } + + #[test] + #[ignore = "requires packaged whisper and model fixtures"] + fn final_prompt_enforces_combined_character_and_token_boundaries() { + let _guard = NATIVE_TEST + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let context = require_context(); + let glossary = "Glossary: MCP, GGUF, Lua."; + + let character_limited = + final_prompt(&context, Some(glossary), &"a".repeat(MAX_PROMPT_CHARS * 2)); + assert_eq!( + character_limited.len(), + MAX_PROMPT_CHARS, + "the combined prompt fills but never exceeds its character budget" + ); + assert!(character_limited.starts_with(glossary)); + assert!(token_count(&context, &character_limited) <= MAX_PROMPT_TOKENS); + + let token_limited = final_prompt(&context, Some(glossary), &"x q z v j ".repeat(200)); + assert!(token_limited.starts_with(glossary)); + assert!(token_limited.len() <= MAX_PROMPT_CHARS); + assert!( + token_count(&context, &token_limited) <= MAX_PROMPT_TOKENS, + "the combined prompt stays within whisper's token budget" + ); + assert!( + token_limited.len() < MAX_PROMPT_CHARS, + "the token budget, not the character budget, limits this fixture" + ); + assert!( + token_limited.trim_end().ends_with("x q z v j"), + "truncation retains the transcript tail" + ); + } +} diff --git a/crates/gateway-stt-backend-whisper/tests/native_whisper.rs b/crates/gateway-stt-backend-whisper/tests/native_whisper.rs new file mode 100644 index 00000000..99df8396 --- /dev/null +++ b/crates/gateway-stt-backend-whisper/tests/native_whisper.rs @@ -0,0 +1,310 @@ +//! Native characterization of the packaged Whisper backend contract. +//! Miri excludes native model loading and decode; packaged-runtime CI owns them. + +#![expect( + clippy::expect_used, + reason = "native fixture setup fails by panicking with the missing invariant named" +)] + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use gateway_stt_backend_whisper::{WhisperConfig, WhisperModelFactory}; +use gateway_stt_engine::test_fixtures::native::require_fixture; +use gateway_stt_engine::{DecodeMode, DecodeRequest, EnginePolicy, SttEngine}; +use shared_progress::{ProgressHandle, ProgressHub}; + +const JFK_TRANSCRIPT: &str = "And so my fellow Americans ask not what your country can do for you, ask what you can do for your country."; +const UNPROMPTED_CLIP_TRANSCRIPT: &str = "country can do for you."; +const GLOSSARY_CLIP_TRANSCRIPT: &str = "One tree can do for you."; +const CONDITIONING_TRANSCRIPT: &str = "And so my fellow Americans asked"; +const CONDITIONED_CLIP_TRANSCRIPT: &str = "what I can do for you."; +const SAMPLES_PER_TENTH: usize = 1_600; +static NATIVE_TEST: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +fn fixture_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") +} + +#[test] +fn native_backend_suite_keeps_its_backend_fixture_root() { + assert_eq!( + fixture_dir(), + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") + ); +} + +fn jfk_samples() -> Vec { + let path = require_fixture("PROMPTFORGE_WHISPER_AUDIO", &fixture_dir(), "jfk.wav"); + let mut reader = hound::WavReader::open(path).expect("JFK fixture opens"); + let spec = reader.spec(); + assert_eq!(spec.sample_rate, 16_000, "fixture must be 16 kHz"); + assert_eq!(spec.channels, 1, "fixture must be mono"); + assert_eq!(spec.bits_per_sample, 16, "fixture must be 16-bit PCM"); + reader + .samples::() + .map(|sample| f32::from(sample.expect("fixture sample decodes")) / 32_768.0) + .collect() +} + +fn engine(library: PathBuf, interim: PathBuf, final_model: Option) -> SttEngine { + engine_with_progress(library, interim, final_model, None) +} + +fn engine_with_progress( + library: PathBuf, + interim: PathBuf, + final_model: Option, + progress: Option, +) -> SttEngine { + let config = WhisperConfig::new(library, interim, final_model, progress); + let factory = WhisperModelFactory::new(config).expect("packaged runtime loads"); + let policy = + EnginePolicy::new(12, 500, factory.gpu_available()).expect("capture policy is valid"); + SttEngine::new(factory, policy).expect("backend models load") +} + +fn request( + mode: DecodeMode, + samples: Vec, + guidance: Vec, + finalized: impl Into, +) -> DecodeRequest { + DecodeRequest::new(mode, samples, guidance, finalized.into()) +} + +#[tokio::test] +#[ignore = "requires packaged whisper, model, and audio fixtures"] +async fn packaged_runtime_preserves_native_transcription_contract() { + let _guard = NATIVE_TEST.lock().await; + let temp = tempfile::tempdir().expect("temporary packaged-runtime directory"); + let library = require_fixture("PROMPTFORGE_WHISPER_LIBRARY", &fixture_dir(), "whisper.dll"); + let model = temp.path().join("ggml-tiny.en.bin"); + std::fs::copy( + require_fixture( + "PROMPTFORGE_WHISPER_MODEL", + &fixture_dir(), + "ggml-tiny.en.bin", + ), + &model, + ) + .expect("copy the exact tiny model fixture"); + let samples = jfk_samples(); + let prompt_sensitive_clip = samples[60 * SAMPLES_PER_TENTH..80 * SAMPLES_PER_TENTH].to_vec(); + let conditioning_clip = samples[..40 * SAMPLES_PER_TENTH].to_vec(); + + let unprompted = engine(library.clone(), model.clone(), Some(model.clone())); + let interim = unprompted + .decode(request( + DecodeMode::Interim, + samples.clone(), + Vec::new(), + "", + )) + .await + .expect("interim decode succeeds"); + assert_eq!(interim, JFK_TRANSCRIPT, "interim decode policy stays fixed"); + + let unprompted_clip = unprompted + .decode(request( + DecodeMode::Final, + prompt_sensitive_clip.clone(), + Vec::new(), + "", + )) + .await + .expect("unprompted final decode succeeds"); + assert_eq!(unprompted_clip, UNPROMPTED_CLIP_TRANSCRIPT); + + let conditioning_transcript = unprompted + .decode(request( + DecodeMode::Final, + conditioning_clip, + Vec::new(), + "", + )) + .await + .expect("conditioning decode succeeds"); + let conditioned_clip = unprompted + .decode(request( + DecodeMode::Final, + prompt_sensitive_clip.clone(), + Vec::new(), + conditioning_transcript.clone(), + )) + .await + .expect("transcript-conditioned final decode succeeds"); + assert_eq!(conditioning_transcript, CONDITIONING_TRANSCRIPT); + assert_eq!(conditioned_clip, CONDITIONED_CLIP_TRANSCRIPT); + assert_ne!(conditioned_clip, unprompted_clip); + + let glossary_prompted = engine(library, model.clone(), Some(model.clone())); + let glossary_clip = glossary_prompted + .decode(request( + DecodeMode::Final, + prompt_sensitive_clip, + vec!["one tree".to_string()], + "", + )) + .await + .expect("the glossary-conditioned segment decodes"); + let silent_tail = glossary_prompted + .decode(request( + DecodeMode::Final, + vec![0.0; 16_000], + vec!["one tree".to_string()], + glossary_clip.clone(), + )) + .await + .expect("the silent tail decodes"); + assert!(silent_tail.is_empty(), "silence remains gated"); + assert_eq!(glossary_clip, GLOSSARY_CLIP_TRANSCRIPT); + assert_ne!(glossary_clip, unprompted_clip); + + drop(glossary_prompted); + drop(unprompted); + std::fs::remove_file(model).expect("dropping the engine releases the model"); +} + +#[tokio::test] +#[ignore = "requires packaged whisper, model, and audio fixtures"] +async fn independent_final_jobs_do_not_require_a_reset() { + let _guard = NATIVE_TEST.lock().await; + let library = require_fixture("PROMPTFORGE_WHISPER_LIBRARY", &fixture_dir(), "whisper.dll"); + let model = require_fixture( + "PROMPTFORGE_WHISPER_MODEL", + &fixture_dir(), + "ggml-tiny.en.bin", + ); + let engine = engine(library, model.clone(), Some(model)); + let samples = jfk_samples(); + + let first = engine + .decode(request(DecodeMode::Final, samples.clone(), Vec::new(), "")) + .await + .expect("first job succeeds"); + let second = engine + .decode(request(DecodeMode::Final, samples, Vec::new(), "")) + .await + .expect("second job succeeds"); + assert_eq!(second, first, "equal stateless jobs remain independent"); +} + +#[tokio::test] +#[ignore = "requires packaged whisper, model, and audio fixtures"] +async fn one_final_job_cannot_change_another_jobs_history() { + let _guard = NATIVE_TEST.lock().await; + let library = require_fixture("PROMPTFORGE_WHISPER_LIBRARY", &fixture_dir(), "whisper.dll"); + let model = require_fixture( + "PROMPTFORGE_WHISPER_MODEL", + &fixture_dir(), + "ggml-tiny.en.bin", + ); + let engine = engine(library, model.clone(), Some(model)); + let samples = jfk_samples(); + let prompt_sensitive = samples[6 * 16_000..8 * 16_000].to_vec(); + + let control = engine + .decode(request( + DecodeMode::Final, + prompt_sensitive.clone(), + Vec::new(), + "", + )) + .await + .expect("control job succeeds"); + let history = engine + .decode(request( + DecodeMode::Final, + samples[..4 * 16_000].to_vec(), + Vec::new(), + "", + )) + .await + .expect("history source succeeds"); + let conditioned = engine + .decode(request( + DecodeMode::Final, + prompt_sensitive.clone(), + Vec::new(), + history, + )) + .await + .expect("conditioned job succeeds"); + assert_ne!(conditioned, control, "fixture detects conditioning"); + + let standalone = engine + .decode(request(DecodeMode::Final, prompt_sensitive, Vec::new(), "")) + .await + .expect("standalone job succeeds"); + assert_eq!( + standalone, control, + "prior job history cannot leak into a stateless decode" + ); +} + +#[tokio::test] +#[ignore = "requires packaged whisper, model, and audio fixtures"] +async fn final_decode_is_absent_without_a_final_model() { + let _guard = NATIVE_TEST.lock().await; + let library = require_fixture("PROMPTFORGE_WHISPER_LIBRARY", &fixture_dir(), "whisper.dll"); + let model = require_fixture( + "PROMPTFORGE_WHISPER_MODEL", + &fixture_dir(), + "ggml-tiny.en.bin", + ); + let engine = engine(library, model, None); + let error = engine + .decode(request(DecodeMode::Final, jfk_samples(), Vec::new(), "")) + .await + .expect_err("an omitted final model leaves no final decoder"); + assert!( + error + .to_string() + .contains("final decoder is not configured"), + "the missing final worker is classified explicitly: {error}" + ); +} + +#[tokio::test] +#[ignore = "requires packaged whisper and model fixtures"] +async fn configured_model_branches_finish_prewarm_and_init_progress() { + let _guard = NATIVE_TEST.lock().await; + let library = require_fixture("PROMPTFORGE_WHISPER_LIBRARY", &fixture_dir(), "whisper.dll"); + let model = require_fixture( + "PROMPTFORGE_WHISPER_MODEL", + &fixture_dir(), + "ggml-tiny.en.bin", + ); + let hub = Arc::new(ProgressHub::new()); + let tree = hub.operation(); + let models = tree.register("models", 1.0); + + let engine = engine_with_progress(library, model.clone(), Some(model), Some(models)); + assert!(engine.has_final_pass(), "the final branch is configured"); + + let snapshot = hub.snapshot(); + let nodes = &snapshot[0].nodes; + for branch in ["interim", "final"] { + let branch_path = format!("models/{branch}"); + assert!( + nodes.iter().any(|node| node.path == branch_path), + "{branch} model progress branch is present: {nodes:?}" + ); + for stage in ["prewarm", "init"] { + let path = format!("{branch_path}/{stage}"); + let node = nodes + .iter() + .find(|node| node.path == path) + .unwrap_or_else(|| panic!("{path} progress is present: {nodes:?}")); + assert!( + node.finished && node.ok, + "{path} reaches a successful terminal state: {node:?}" + ); + assert!( + (node.fraction - 1.0).abs() < f64::EPSILON, + "{path} completes all work: {node:?}" + ); + } + } +} diff --git a/crates/gateway-stt-engine/AGENTS.md b/crates/gateway-stt-engine/AGENTS.md new file mode 100644 index 00000000..57913d57 --- /dev/null +++ b/crates/gateway-stt-engine/AGENTS.md @@ -0,0 +1,8 @@ +# gateway-stt-engine + +This crate owns backend-neutral stateless transcription workers and shared audio policy. + +- Decode jobs are stateless: workers retain no guidance, history, transcript, session, or take state between jobs. +- Blocking decoders stay on their owning threads; callers hand over owned buffers and await replies without blocking the async executor. +- Startup deadlines classify non-preemptible construction without claiming cancellation; ordinary shutdown joins every worker and surfaces join panic. +- This crate never depends on a backend, host, HTTP, WebSocket, or UI crate. diff --git a/crates/gateway-stt-engine/Cargo.toml b/crates/gateway-stt-engine/Cargo.toml new file mode 100644 index 00000000..979ab227 --- /dev/null +++ b/crates/gateway-stt-engine/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "gateway-stt-engine" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +description = "Backend-neutral PromptForge speech decoding workers and audio policy" + +[dependencies] +thiserror.workspace = true +tokio.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[features] +test-fixtures = [] + +[lints] +workspace = true diff --git a/crates/gateway-stt-engine/module-ceilings.toml b/crates/gateway-stt-engine/module-ceilings.toml new file mode 100644 index 00000000..1636f4df --- /dev/null +++ b/crates/gateway-stt-engine/module-ceilings.toml @@ -0,0 +1,23 @@ +# Exact source and public-root counts for the backend-neutral STT engine. +# Physical lines include comments and blanks. Every recorded ceiling equals +# the measured file size, so any size change updates this manifest explicitly. + +public_root_count = 7 +test_fixture_public_root_count = 8 + +[modules] +"decoder.rs" = 87 +"engine.rs" = 251 +"error.rs" = 83 +"lib.rs" = 18 +"policy.rs" = 132 +"startup.rs" = 48 +"test_fixtures.rs" = 201 +"test_fixtures/native.rs" = 24 +"test_fixtures/scenarios.rs" = 294 +"test_fixtures/tests.rs" = 246 +"test_fixtures/tests/scenario_cleanup.rs" = 56 +"test_fixtures/tests/scenario_cleanup/construction.rs" = 100 +"test_fixtures/tests/scenario_cleanup/decode.rs" = 150 +"translation.rs" = 50 +"worker.rs" = 460 diff --git a/crates/gateway-stt-engine/public-api-default.txt b/crates/gateway-stt-engine/public-api-default.txt new file mode 100644 index 00000000..db3ec8ba --- /dev/null +++ b/crates/gateway-stt-engine/public-api-default.txt @@ -0,0 +1,63 @@ +pub mod gateway_stt_engine +pub enum gateway_stt_engine::DecodeMode +pub gateway_stt_engine::DecodeMode::Final +pub gateway_stt_engine::DecodeMode::Interim +#[non_exhaustive] pub enum gateway_stt_engine::TranscribeError +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::FinalStartupTimedOut +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::Inference(alloc::boxed::Box<(dyn core::error::Error + core::marker::Send + core::marker::Sync)>) +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::InitializeBackend(alloc::boxed::Box<(dyn core::error::Error + core::marker::Send + core::marker::Sync)>) +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::InterimStartupTimedOut +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::InvalidConfig(alloc::string::String) +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::LoadModel +pub gateway_stt_engine::TranscribeError::LoadModel::path: std::path::PathBuf +pub gateway_stt_engine::TranscribeError::LoadModel::source: alloc::boxed::Box<(dyn core::error::Error + core::marker::Send + core::marker::Sync)> +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::Overloaded +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::ShutdownFailures +pub gateway_stt_engine::TranscribeError::ShutdownFailures::cleanup: alloc::vec::Vec +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::ShutdownPanicked +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::SpawnWorker(core::io::error::Error) +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::StartupCleanup +pub gateway_stt_engine::TranscribeError::StartupCleanup::cleanup: alloc::vec::Vec +pub gateway_stt_engine::TranscribeError::StartupCleanup::startup: alloc::boxed::Box +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::StartupFailures +pub gateway_stt_engine::TranscribeError::StartupFailures::failures: alloc::vec::Vec +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::WorkerGone +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::WorkerPanicked +impl gateway_stt_engine::TranscribeError +pub fn gateway_stt_engine::TranscribeError::inference(impl core::error::Error + core::marker::Send + core::marker::Sync + 'static) -> Self +pub fn gateway_stt_engine::TranscribeError::initialize_backend(impl core::error::Error + core::marker::Send + core::marker::Sync + 'static) -> Self +pub fn gateway_stt_engine::TranscribeError::is_non_preemptible_startup_timeout(&self) -> bool +pub fn gateway_stt_engine::TranscribeError::load_model(std::path::PathBuf, impl core::error::Error + core::marker::Send + core::marker::Sync + 'static) -> Self +pub struct gateway_stt_engine::DecodeRequest +impl gateway_stt_engine::DecodeRequest +pub fn gateway_stt_engine::DecodeRequest::finalized(&self) -> &str +pub fn gateway_stt_engine::DecodeRequest::guidance(&self) -> &[alloc::string::String] +pub fn gateway_stt_engine::DecodeRequest::mode(&self) -> gateway_stt_engine::DecodeMode +pub fn gateway_stt_engine::DecodeRequest::new(gateway_stt_engine::DecodeMode, alloc::vec::Vec, alloc::vec::Vec, alloc::string::String) -> Self +pub fn gateway_stt_engine::DecodeRequest::samples(&self) -> &[f32] +pub struct gateway_stt_engine::EnginePolicy +impl gateway_stt_engine::EnginePolicy +pub const gateway_stt_engine::EnginePolicy::MIN_WINDOW_SAMPLES: usize +pub const gateway_stt_engine::EnginePolicy::SAMPLE_RATE: usize +pub fn gateway_stt_engine::EnginePolicy::gpu_available(self) -> bool +pub fn gateway_stt_engine::EnginePolicy::interval(self) -> core::time::Duration +pub fn gateway_stt_engine::EnginePolicy::is_silence(&[f32]) -> bool +pub fn gateway_stt_engine::EnginePolicy::new(u64, u64, bool) -> core::result::Result +pub fn gateway_stt_engine::EnginePolicy::startup_timeout(self) -> core::time::Duration +pub fn gateway_stt_engine::EnginePolicy::window_samples(self) -> usize +pub fn gateway_stt_engine::EnginePolicy::with_startup_timeout(self, core::time::Duration) -> Self +pub struct gateway_stt_engine::SttEngine +impl gateway_stt_engine::SttEngine +pub async fn gateway_stt_engine::SttEngine::decode(&self, gateway_stt_engine::DecodeRequest) -> core::result::Result +pub fn gateway_stt_engine::SttEngine::gpu_transcription_available(&self) -> bool +pub fn gateway_stt_engine::SttEngine::has_final_pass(&self) -> bool +pub fn gateway_stt_engine::SttEngine::interval(&self) -> core::time::Duration +pub fn gateway_stt_engine::SttEngine::new(impl gateway_stt_engine::ModelFactory, gateway_stt_engine::EnginePolicy) -> core::result::Result +pub fn gateway_stt_engine::SttEngine::shutdown(&self) -> core::result::Result<(), gateway_stt_engine::TranscribeError> +pub fn gateway_stt_engine::SttEngine::window_samples(&self) -> usize +impl core::ops::drop::Drop for gateway_stt_engine::SttEngine +pub fn gateway_stt_engine::SttEngine::drop(&mut self) +pub trait gateway_stt_engine::Decoder +pub fn gateway_stt_engine::Decoder::decode(&mut self, gateway_stt_engine::DecodeRequest) -> core::result::Result +pub trait gateway_stt_engine::ModelFactory: core::fmt::Debug + core::marker::Send + core::marker::Sync + 'static +pub fn gateway_stt_engine::ModelFactory::create(&self, gateway_stt_engine::DecodeMode) -> core::result::Result>, gateway_stt_engine::TranscribeError> diff --git a/crates/gateway-stt-engine/public-api-test-fixtures.txt b/crates/gateway-stt-engine/public-api-test-fixtures.txt new file mode 100644 index 00000000..be291305 --- /dev/null +++ b/crates/gateway-stt-engine/public-api-test-fixtures.txt @@ -0,0 +1,97 @@ +pub mod gateway_stt_engine +pub mod gateway_stt_engine::test_fixtures +pub mod gateway_stt_engine::test_fixtures::native +pub fn gateway_stt_engine::test_fixtures::native::require_fixture(&str, &std::path::Path, &str) -> std::path::PathBuf +pub struct gateway_stt_engine::test_fixtures::ScriptedDecoder +impl gateway_stt_engine::test_fixtures::ScriptedDecoder +pub fn gateway_stt_engine::test_fixtures::ScriptedDecoder::creation_thread(&self) -> core::option::Option +pub fn gateway_stt_engine::test_fixtures::ScriptedDecoder::decode_threads(&self) -> alloc::vec::Vec +pub fn gateway_stt_engine::test_fixtures::ScriptedDecoder::fail_next_construction(&self, impl core::convert::Into) +pub fn gateway_stt_engine::test_fixtures::ScriptedDecoder::new() -> Self +pub fn gateway_stt_engine::test_fixtures::ScriptedDecoder::panic_next(&self) +pub fn gateway_stt_engine::test_fixtures::ScriptedDecoder::panic_on_drop(&self) +pub fn gateway_stt_engine::test_fixtures::ScriptedDecoder::push_error(&self, impl core::convert::Into) +pub fn gateway_stt_engine::test_fixtures::ScriptedDecoder::push_text(&self, impl core::convert::Into) +pub fn gateway_stt_engine::test_fixtures::ScriptedDecoder::requests(&self) -> alloc::vec::Vec +pub fn gateway_stt_engine::test_fixtures::ScriptedDecoder::wait_for_completed(&self, usize, core::time::Duration) -> bool +pub fn gateway_stt_engine::test_fixtures::ScriptedDecoder::wait_for_requests(&self, usize, core::time::Duration) -> bool +pub fn gateway_stt_engine::test_fixtures::ScriptedDecoder::wait_until_worker_dropped(&self, core::time::Duration) -> bool +pub async fn gateway_stt_engine::test_fixtures::ScriptedDecoder::with_next_decode_blocked(&self, core::time::Duration, Start, Scenario) -> core::option::Option where Start: core::ops::function::FnOnce() -> Started, Started: core::future::future::Future, Scenario: core::ops::function::FnOnce(Context) -> Running, Running: core::future::future::Future +pub fn gateway_stt_engine::test_fixtures::ScriptedDecoder::worker_dropped(&self) -> bool +pub struct gateway_stt_engine::test_fixtures::ScriptedModelFactory +impl gateway_stt_engine::test_fixtures::ScriptedModelFactory +pub fn gateway_stt_engine::test_fixtures::ScriptedModelFactory::gpu_available(&self) -> bool +pub fn gateway_stt_engine::test_fixtures::ScriptedModelFactory::new(gateway_stt_engine::test_fixtures::ScriptedDecoder) -> Self +pub fn gateway_stt_engine::test_fixtures::ScriptedModelFactory::with_construction_blocked(self, core::time::Duration, core::time::Duration, Start, Scenario) -> core::option::Option<(Result, Observation)> where Start: core::ops::function::FnOnce(Self) -> Result + core::marker::Send, Result: core::marker::Send, Scenario: core::ops::function::FnOnce() -> Observation +pub fn gateway_stt_engine::test_fixtures::ScriptedModelFactory::with_final(self, gateway_stt_engine::test_fixtures::ScriptedDecoder) -> Self +pub fn gateway_stt_engine::test_fixtures::ScriptedModelFactory::with_final_failure(self, impl core::convert::Into) -> Self +pub fn gateway_stt_engine::test_fixtures::ScriptedModelFactory::with_final_panic(self) -> Self +pub fn gateway_stt_engine::test_fixtures::ScriptedModelFactory::with_gpu_available(self, bool) -> Self +pub fn gateway_stt_engine::test_fixtures::ScriptedModelFactory::with_interim_failure(self, impl core::convert::Into) -> Self +pub fn gateway_stt_engine::test_fixtures::ScriptedModelFactory::with_interim_panic(self) -> Self +impl gateway_stt_engine::ModelFactory for gateway_stt_engine::test_fixtures::ScriptedModelFactory +pub fn gateway_stt_engine::test_fixtures::ScriptedModelFactory::create(&self, gateway_stt_engine::DecodeMode) -> core::result::Result>, gateway_stt_engine::TranscribeError> +pub enum gateway_stt_engine::DecodeMode +pub gateway_stt_engine::DecodeMode::Final +pub gateway_stt_engine::DecodeMode::Interim +#[non_exhaustive] pub enum gateway_stt_engine::TranscribeError +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::FinalStartupTimedOut +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::Inference(alloc::boxed::Box<(dyn core::error::Error + core::marker::Send + core::marker::Sync)>) +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::InitializeBackend(alloc::boxed::Box<(dyn core::error::Error + core::marker::Send + core::marker::Sync)>) +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::InterimStartupTimedOut +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::InvalidConfig(alloc::string::String) +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::LoadModel +pub gateway_stt_engine::TranscribeError::LoadModel::path: std::path::PathBuf +pub gateway_stt_engine::TranscribeError::LoadModel::source: alloc::boxed::Box<(dyn core::error::Error + core::marker::Send + core::marker::Sync)> +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::Overloaded +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::ShutdownFailures +pub gateway_stt_engine::TranscribeError::ShutdownFailures::cleanup: alloc::vec::Vec +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::ShutdownPanicked +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::SpawnWorker(core::io::error::Error) +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::StartupCleanup +pub gateway_stt_engine::TranscribeError::StartupCleanup::cleanup: alloc::vec::Vec +pub gateway_stt_engine::TranscribeError::StartupCleanup::startup: alloc::boxed::Box +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::StartupFailures +pub gateway_stt_engine::TranscribeError::StartupFailures::failures: alloc::vec::Vec +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::WorkerGone +#[non_exhaustive] pub gateway_stt_engine::TranscribeError::WorkerPanicked +impl gateway_stt_engine::TranscribeError +pub fn gateway_stt_engine::TranscribeError::inference(impl core::error::Error + core::marker::Send + core::marker::Sync + 'static) -> Self +pub fn gateway_stt_engine::TranscribeError::initialize_backend(impl core::error::Error + core::marker::Send + core::marker::Sync + 'static) -> Self +pub fn gateway_stt_engine::TranscribeError::is_non_preemptible_startup_timeout(&self) -> bool +pub fn gateway_stt_engine::TranscribeError::load_model(std::path::PathBuf, impl core::error::Error + core::marker::Send + core::marker::Sync + 'static) -> Self +pub struct gateway_stt_engine::DecodeRequest +impl gateway_stt_engine::DecodeRequest +pub fn gateway_stt_engine::DecodeRequest::finalized(&self) -> &str +pub fn gateway_stt_engine::DecodeRequest::guidance(&self) -> &[alloc::string::String] +pub fn gateway_stt_engine::DecodeRequest::mode(&self) -> gateway_stt_engine::DecodeMode +pub fn gateway_stt_engine::DecodeRequest::new(gateway_stt_engine::DecodeMode, alloc::vec::Vec, alloc::vec::Vec, alloc::string::String) -> Self +pub fn gateway_stt_engine::DecodeRequest::samples(&self) -> &[f32] +pub struct gateway_stt_engine::EnginePolicy +impl gateway_stt_engine::EnginePolicy +pub const gateway_stt_engine::EnginePolicy::MIN_WINDOW_SAMPLES: usize +pub const gateway_stt_engine::EnginePolicy::SAMPLE_RATE: usize +pub fn gateway_stt_engine::EnginePolicy::gpu_available(self) -> bool +pub fn gateway_stt_engine::EnginePolicy::interval(self) -> core::time::Duration +pub fn gateway_stt_engine::EnginePolicy::is_silence(&[f32]) -> bool +pub fn gateway_stt_engine::EnginePolicy::new(u64, u64, bool) -> core::result::Result +pub fn gateway_stt_engine::EnginePolicy::startup_timeout(self) -> core::time::Duration +pub fn gateway_stt_engine::EnginePolicy::window_samples(self) -> usize +pub fn gateway_stt_engine::EnginePolicy::with_startup_timeout(self, core::time::Duration) -> Self +pub struct gateway_stt_engine::SttEngine +impl gateway_stt_engine::SttEngine +pub async fn gateway_stt_engine::SttEngine::decode(&self, gateway_stt_engine::DecodeRequest) -> core::result::Result +pub fn gateway_stt_engine::SttEngine::gpu_transcription_available(&self) -> bool +pub fn gateway_stt_engine::SttEngine::has_final_pass(&self) -> bool +pub fn gateway_stt_engine::SttEngine::interval(&self) -> core::time::Duration +pub fn gateway_stt_engine::SttEngine::new(impl gateway_stt_engine::ModelFactory, gateway_stt_engine::EnginePolicy) -> core::result::Result +pub fn gateway_stt_engine::SttEngine::shutdown(&self) -> core::result::Result<(), gateway_stt_engine::TranscribeError> +pub fn gateway_stt_engine::SttEngine::window_samples(&self) -> usize +impl core::ops::drop::Drop for gateway_stt_engine::SttEngine +pub fn gateway_stt_engine::SttEngine::drop(&mut self) +pub trait gateway_stt_engine::Decoder +pub fn gateway_stt_engine::Decoder::decode(&mut self, gateway_stt_engine::DecodeRequest) -> core::result::Result +pub trait gateway_stt_engine::ModelFactory: core::fmt::Debug + core::marker::Send + core::marker::Sync + 'static +pub fn gateway_stt_engine::ModelFactory::create(&self, gateway_stt_engine::DecodeMode) -> core::result::Result>, gateway_stt_engine::TranscribeError> +impl gateway_stt_engine::ModelFactory for gateway_stt_engine::test_fixtures::ScriptedModelFactory +pub fn gateway_stt_engine::test_fixtures::ScriptedModelFactory::create(&self, gateway_stt_engine::DecodeMode) -> core::result::Result>, gateway_stt_engine::TranscribeError> diff --git a/crates/gateway-stt-engine/src/decoder.rs b/crates/gateway-stt-engine/src/decoder.rs new file mode 100644 index 00000000..9e73e7b8 --- /dev/null +++ b/crates/gateway-stt-engine/src/decoder.rs @@ -0,0 +1,87 @@ +//! Backend-neutral model construction and stateless decoding contracts. + +use std::fmt::Debug; + +use crate::TranscribeError; + +/// Selects the physical worker and backend decode policy for one request. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DecodeMode { + /// Responsive provisional transcription. + Interim, + /// Accurate authoritative transcription. + Final, +} + +/// One complete stateless decode job. +#[derive(Clone, Debug)] +pub struct DecodeRequest { + mode: DecodeMode, + samples: Vec, + guidance: Vec, + finalized: String, +} + +impl DecodeRequest { + /// Creates one owned decode request. + #[must_use] + pub fn new( + mode: DecodeMode, + samples: Vec, + guidance: Vec, + finalized: String, + ) -> Self { + Self { + mode, + samples, + guidance, + finalized, + } + } + + /// Requested worker and decode policy. + #[must_use] + pub fn mode(&self) -> DecodeMode { + self.mode + } + + /// Owned mono 16 kHz floating-point PCM. + #[must_use] + pub fn samples(&self) -> &[f32] { + &self.samples + } + + /// Immutable user guidance for this job. + #[must_use] + pub fn guidance(&self) -> &[String] { + &self.guidance + } + + /// Finalized transcript history for this job. + #[must_use] + pub fn finalized(&self) -> &str { + &self.finalized + } +} + +/// One backend decoder confined to a transcription worker thread. +/// +/// Implementations must not retain request state between calls. +pub trait Decoder { + /// Decodes one owned worker job. + /// + /// # Errors + /// Returns a backend-translated transcription failure. + fn decode(&mut self, request: DecodeRequest) -> Result; +} + +/// Constructs backend decoders on the worker threads that own them. +pub trait ModelFactory: Debug + Send + Sync + 'static { + /// Constructs the decoder for `mode`. + /// + /// `None` is valid only for an unconfigured final worker. + /// + /// # Errors + /// Returns a backend-translated model construction failure. + fn create(&self, mode: DecodeMode) -> Result>, TranscribeError>; +} diff --git a/crates/gateway-stt-engine/src/engine.rs b/crates/gateway-stt-engine/src/engine.rs new file mode 100644 index 00000000..802393d2 --- /dev/null +++ b/crates/gateway-stt-engine/src/engine.rs @@ -0,0 +1,251 @@ +//! Backend-neutral interim and final transcription workers. + +use std::sync::Arc; + +use crate::startup; +use crate::worker::{FINAL_JOB_CAPACITY, INTERIM_JOB_CAPACITY, Transcriber}; +use crate::{DecodeMode, DecodeRequest, EnginePolicy, ModelFactory, TranscribeError}; + +/// The STT engine: one required interim worker and one optional final worker. +#[derive(Debug)] +pub struct SttEngine { + transcriber: Transcriber, + final_pass: Option, + policy: EnginePolicy, +} + +impl SttEngine { + /// Builds backend decoders on their owning worker threads. + /// + /// # Errors + /// Returns a backend-translated construction failure, + /// one or more role-specific startup failures, or + /// [`TranscribeError::SpawnWorker`]. If joining partially started workers + /// also fails, [`TranscribeError::StartupCleanup`] preserves both outcomes. + pub fn new(factory: impl ModelFactory, policy: EnginePolicy) -> Result { + Self::new_with(factory, policy, Transcriber::spawn) + } + + fn new_with( + factory: impl ModelFactory, + policy: EnginePolicy, + mut spawn: impl FnMut( + &'static str, + Arc, + DecodeMode, + usize, + ) -> Result< + ( + Transcriber, + std::sync::mpsc::Receiver>, + ), + TranscribeError, + >, + ) -> Result { + let factory: Arc = Arc::new(factory); + let startup_deadline = std::time::Instant::now() + .checked_add(policy.startup_timeout()) + .ok_or_else(|| { + TranscribeError::InvalidConfig("stt.startup_timeout is too large".to_owned()) + })?; + let (transcriber, interim_init) = spawn( + "stt-interim", + Arc::clone(&factory), + DecodeMode::Interim, + INTERIM_JOB_CAPACITY, + )?; + let (final_worker, final_init) = match spawn( + "stt-final", + Arc::clone(&factory), + DecodeMode::Final, + FINAL_JOB_CAPACITY, + ) { + Ok(worker) => worker, + Err(final_spawn) => { + let interim = + startup::outcome(&interim_init, DecodeMode::Interim, startup_deadline); + let interim_timed_out = startup::timed_out(&interim); + let Err(startup) = startup::pair(interim, Err(final_spawn)) else { + unreachable!("the final spawn failure prevents construction"); + }; + let cleanup = if interim_timed_out { + transcriber.abandon_startup(); + Vec::new() + } else { + vec![transcriber.shutdown()] + }; + return Err(Transcriber::startup_failure(startup, cleanup)); + } + }; + let interim = startup::outcome(&interim_init, DecodeMode::Interim, startup_deadline); + let final_result = startup::outcome(&final_init, DecodeMode::Final, startup_deadline); + let interim_timed_out = startup::timed_out(&interim); + let final_timed_out = startup::timed_out(&final_result); + let (interim_exists, final_exists) = match startup::pair(interim, final_result) { + Ok(pair) => pair, + Err(startup) => { + let mut cleanup = Vec::with_capacity(2); + if interim_timed_out { + transcriber.abandon_startup(); + } else { + cleanup.push(transcriber.shutdown()); + } + if final_timed_out { + final_worker.abandon_startup(); + } else { + cleanup.push(final_worker.shutdown()); + } + return Err(Transcriber::startup_failure(startup, cleanup)); + } + }; + debug_assert!(interim_exists); + let final_pass = if final_exists { + Some(final_worker) + } else { + final_worker.shutdown()?; + None + }; + + Ok(Self { + transcriber, + final_pass, + policy, + }) + } + + /// Whether the final pass is configured. + #[must_use] + pub fn has_final_pass(&self) -> bool { + self.final_pass.is_some() + } + + /// Whether the backend reports hardware acceleration. + #[must_use] + pub fn gpu_transcription_available(&self) -> bool { + self.policy.gpu_available() + } + + /// Samples in the sliding interim window. + #[must_use] + pub fn window_samples(&self) -> usize { + self.policy.window_samples() + } + + /// Cadence of the interim loop. + #[must_use] + pub fn interval(&self) -> std::time::Duration { + self.policy.interval() + } + + /// Decodes one explicit stateless request on its selected worker. + /// + /// # Errors + /// Returns a decoder failure, [`TranscribeError::WorkerGone`], or an + /// invalid-configuration error when a final worker was not configured. + pub async fn decode(&self, request: DecodeRequest) -> Result { + match request.mode() { + DecodeMode::Interim => self.transcriber.transcribe(request).await, + DecodeMode::Final => match &self.final_pass { + Some(final_pass) => final_pass.transcribe(request).await, + None => Err(TranscribeError::InvalidConfig( + "the final decoder is not configured".to_owned(), + )), + }, + } + } + + /// Closes both worker queues and joins their threads. + /// + /// Calling this method more than once has no additional effect. Native + /// decoding is non-preemptible, so shutdown waits for a running decode + /// rather than detaching its worker. + /// # Errors + /// Returns [`TranscribeError::ShutdownPanicked`] for one panicked worker or + /// [`TranscribeError::ShutdownFailures`] for multiple panicked workers. + /// Both workers are still joined and every failure remains visible on + /// repeated calls. + pub fn shutdown(&self) -> Result<(), TranscribeError> { + let mut cleanup = Vec::with_capacity(2); + if let Err(error) = self.transcriber.shutdown() { + cleanup.push(error); + } + if let Some(final_pass) = &self.final_pass + && let Err(error) = final_pass.shutdown() + { + cleanup.push(error); + } + if cleanup.len() > 1 { + return Err(TranscribeError::ShutdownFailures { cleanup }); + } + match cleanup.pop() { + Some(error) => Err(error), + None => Ok(()), + } + } +} + +impl Drop for SttEngine { + fn drop(&mut self) { + // Explicit shutdown surfaces join panics. Drop cannot return one. + drop(self.shutdown()); + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Barrier}; + + use crate::Decoder; + + use super::*; + + fn policy() -> EnginePolicy { + EnginePolicy::new(15, 500, false).expect("test policy is valid") + } + + #[derive(Debug)] + struct ConcurrentInterimFailure(Arc); + + impl ModelFactory for ConcurrentInterimFailure { + fn create(&self, mode: DecodeMode) -> Result>, TranscribeError> { + assert_eq!(mode, DecodeMode::Interim); + self.0.wait(); + Err(TranscribeError::InvalidConfig( + "interim startup sentinel".to_owned(), + )) + } + } + + #[test] + fn final_spawn_failure_preserves_concurrent_interim_startup_failure() { + let rendezvous = Arc::new(Barrier::new(2)); + let spawn_rendezvous = Arc::clone(&rendezvous); + let error = SttEngine::new_with( + ConcurrentInterimFailure(rendezvous), + policy(), + move |name, factory, mode, capacity| { + if mode == DecodeMode::Final { + spawn_rendezvous.wait(); + return Err(TranscribeError::SpawnWorker(std::io::Error::other( + "final spawn sentinel", + ))); + } + Transcriber::spawn(name, factory, mode, capacity) + }, + ) + .expect_err("both concurrent startup failures prevent construction"); + let TranscribeError::StartupFailures { failures, .. } = error else { + panic!("both observed role failures must be aggregated"); + }; + assert_eq!(failures.len(), 2); + assert!(matches!( + &failures[0], + TranscribeError::InvalidConfig(message) if message == "interim startup sentinel" + )); + assert!(matches!( + &failures[1], + TranscribeError::SpawnWorker(source) + if source.to_string() == "final spawn sentinel" + )); + } +} diff --git a/crates/gateway-stt-engine/src/error.rs b/crates/gateway-stt-engine/src/error.rs new file mode 100644 index 00000000..026f864b --- /dev/null +++ b/crates/gateway-stt-engine/src/error.rs @@ -0,0 +1,83 @@ +//! Backend-neutral STT engine construction and transcription failures. + +use std::path::PathBuf; + +/// An STT engine construction or transcription failure. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum TranscribeError { + /// The selected transcription backend could not be initialized. + #[non_exhaustive] + #[error("initialize transcription backend")] + InitializeBackend(#[source] Box), + /// The transcription model file could not be loaded. + #[non_exhaustive] + #[error("load transcription model {}", path.display())] + LoadModel { + /// The model path that failed to load. + path: PathBuf, + /// The underlying backend error. + #[source] + source: Box, + }, + /// The transcription worker thread could not be started. + #[non_exhaustive] + #[error("spawn transcription worker")] + SpawnWorker(#[source] std::io::Error), + /// The decoder rejected an audio window. + #[non_exhaustive] + #[error("transcribe audio window")] + Inference(#[source] Box), + /// The transcription worker exited while requests were in flight. + #[non_exhaustive] + #[error("transcription worker exited")] + WorkerGone, + /// The selected model worker has no free queue slot. + #[non_exhaustive] + #[error("transcription worker queue is full")] + Overloaded, + /// Model construction or decoding panicked on its worker thread. + #[non_exhaustive] + #[error("transcription worker panicked")] + WorkerPanicked, + /// Interim model construction did not report an outcome before its deadline. + #[non_exhaustive] + #[error("interim transcription worker startup timed out")] + InterimStartupTimedOut, + /// Final model construction did not report an outcome before its deadline. + #[non_exhaustive] + #[error("final transcription worker startup timed out")] + FinalStartupTimedOut, + /// Multiple worker startup outcomes failed during the shared deadline. + #[non_exhaustive] + #[error("multiple transcription workers failed during startup")] + StartupFailures { + /// Every observed startup failure, ordered interim then final. + failures: Vec, + }, + /// A worker thread panicked while shutdown joined it. + #[non_exhaustive] + #[error("transcription worker panicked during shutdown")] + ShutdownPanicked, + /// Multiple worker threads panicked while shutdown joined them. + #[non_exhaustive] + #[error("multiple transcription workers panicked during shutdown")] + ShutdownFailures { + /// Every failure observed while joining the workers. + cleanup: Vec, + }, + /// Worker startup failed and partial-startup cleanup also failed. + #[non_exhaustive] + #[error("transcription worker startup failed and cleanup also failed")] + StartupCleanup { + /// The startup failure that caused construction to stop. + #[source] + startup: Box, + /// Every failure observed while joining partially started workers. + cleanup: Vec, + }, + /// The STT engine configuration is invalid. + #[non_exhaustive] + #[error("invalid STT configuration: {0}")] + InvalidConfig(String), +} diff --git a/crates/gateway-stt-engine/src/lib.rs b/crates/gateway-stt-engine/src/lib.rs new file mode 100644 index 00000000..d7015e81 --- /dev/null +++ b/crates/gateway-stt-engine/src/lib.rs @@ -0,0 +1,18 @@ +//! Backend-neutral speech decoding on dedicated worker threads. +//! [`SttEngine`] owns an interim decoder and an optional final decoder. +//! Backends implement [`ModelFactory`] and [`Decoder`], while callers retain +//! session, prompt input, transcript, and publication state. +mod decoder; +mod engine; +mod error; +mod policy; +mod startup; +#[cfg(feature = "test-fixtures")] +pub mod test_fixtures; +mod translation; +mod worker; + +pub use decoder::{DecodeMode, DecodeRequest, Decoder, ModelFactory}; +pub use engine::SttEngine; +pub use error::TranscribeError; +pub use policy::EnginePolicy; diff --git a/crates/gateway-stt-engine/src/policy.rs b/crates/gateway-stt-engine/src/policy.rs new file mode 100644 index 00000000..4e8a9770 --- /dev/null +++ b/crates/gateway-stt-engine/src/policy.rs @@ -0,0 +1,132 @@ +//! Backend-neutral worker and audio policy. + +use std::time::Duration; + +use crate::TranscribeError; + +const SILENCE_RMS: f64 = 0.001; +const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(120); + +/// Checked capture, startup, and backend capability policy. +#[derive(Clone, Copy, Debug)] +pub struct EnginePolicy { + window_samples: usize, + interval: Duration, + startup_timeout: Duration, + gpu_available: bool, +} + +impl EnginePolicy { + /// PCM sample rate the streaming wire format and decoders require. + pub const SAMPLE_RATE: usize = 16_000; + + /// Minimum audio the interim loop bothers to transcribe. + pub const MIN_WINDOW_SAMPLES: usize = Self::SAMPLE_RATE / 2; + + /// Validates host capture policy and applies the bounded startup deadline. + /// + /// # Errors + /// Returns [`TranscribeError::InvalidConfig`] for zero or overflowing + /// capture policy values. + pub fn new( + window_seconds: u64, + interval_ms: u64, + gpu_available: bool, + ) -> Result { + if window_seconds == 0 { + return Err(TranscribeError::InvalidConfig( + "stt.window_seconds must be at least 1".to_owned(), + )); + } + if interval_ms == 0 { + return Err(TranscribeError::InvalidConfig( + "stt.interval_ms must be at least 1".to_owned(), + )); + } + let seconds = usize::try_from(window_seconds).map_err(|_| { + TranscribeError::InvalidConfig("stt.window_seconds is too large".to_owned()) + })?; + let window_samples = seconds.checked_mul(Self::SAMPLE_RATE).ok_or_else(|| { + TranscribeError::InvalidConfig("stt.window_seconds is too large".to_owned()) + })?; + Ok(Self { + window_samples, + interval: Duration::from_millis(interval_ms), + startup_timeout: DEFAULT_STARTUP_TIMEOUT, + gpu_available, + }) + } + + /// Overrides the construction deadline for deterministic hosts and tests. + #[must_use] + pub fn with_startup_timeout(mut self, timeout: Duration) -> Self { + self.startup_timeout = timeout; + self + } + + /// Samples in the sliding interim window. + #[must_use] + pub fn window_samples(self) -> usize { + self.window_samples + } + + /// Cadence of the interim loop. + #[must_use] + pub fn interval(self) -> Duration { + self.interval + } + + /// Maximum shared wait for all worker construction outcomes. + #[must_use] + pub fn startup_timeout(self) -> Duration { + self.startup_timeout + } + + /// Whether the backend reports hardware acceleration. + #[must_use] + pub fn gpu_available(self) -> bool { + self.gpu_available + } + + /// Returns true when the buffer is quiet enough that a decoder would + /// hallucinate rather than transcribe. + #[must_use] + pub fn is_silence(samples: &[f32]) -> bool { + rms(samples) < SILENCE_RMS + } +} + +#[expect( + clippy::cast_precision_loss, + reason = "audio buffers are far below 2^53 samples" +)] +fn rms(samples: &[f32]) -> f64 { + if samples.is_empty() { + return 0.0; + } + let energy: f64 = samples.iter().map(|&s| f64::from(s) * f64::from(s)).sum(); + (energy / samples.len() as f64).sqrt() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rms_of_silence_is_zero() { + assert_eq!(rms(&[]).to_bits(), 0.0f64.to_bits()); + assert_eq!(rms(&[0.0; 1600]).to_bits(), 0.0f64.to_bits()); + } + + #[test] + fn rms_of_a_constant_signal_is_its_amplitude() { + assert!((rms(&[0.5; 100]) - 0.5).abs() < 1e-9); + } + + #[test] + fn silence_gate_separates_quiet_from_speech() { + assert!(EnginePolicy::is_silence(&[0.0; 1600])); + assert!(EnginePolicy::is_silence(&[0.0005; 1600])); + assert!(!EnginePolicy::is_silence(&[0.05; 1600])); + } +} diff --git a/crates/gateway-stt-engine/src/startup.rs b/crates/gateway-stt-engine/src/startup.rs new file mode 100644 index 00000000..4f0778ae --- /dev/null +++ b/crates/gateway-stt-engine/src/startup.rs @@ -0,0 +1,48 @@ +//! Shared worker startup deadline and partial-construction cleanup. + +use std::sync::mpsc; +use std::time::Instant; + +use crate::{DecodeMode, TranscribeError}; + +pub(crate) fn outcome( + outcome: &mpsc::Receiver>, + mode: DecodeMode, + deadline: Instant, +) -> Result { + match outcome.recv_timeout(deadline.saturating_duration_since(Instant::now())) { + Ok(result) => result, + Err(mpsc::RecvTimeoutError::Disconnected) => Err(TranscribeError::WorkerGone), + Err(mpsc::RecvTimeoutError::Timeout) => Err(match mode { + DecodeMode::Interim => TranscribeError::InterimStartupTimedOut, + DecodeMode::Final => TranscribeError::FinalStartupTimedOut, + }), + } +} + +pub(crate) fn timed_out(outcome: &Result) -> bool { + matches!( + outcome, + Err(TranscribeError::InterimStartupTimedOut | TranscribeError::FinalStartupTimedOut) + ) +} + +pub(crate) fn pair( + interim: Result, + final_result: Result, +) -> Result<(bool, bool), TranscribeError> { + let interim = match interim { + Ok(true) => Ok(()), + Ok(false) => Err(TranscribeError::InvalidConfig( + "the interim decoder is required".to_owned(), + )), + Err(error) => Err(error), + }; + match (interim, final_result) { + (Ok(()), Ok(final_exists)) => Ok((true, final_exists)), + (Err(interim), Err(final_error)) => Err(TranscribeError::StartupFailures { + failures: vec![interim, final_error], + }), + (Err(error), Ok(_)) | (Ok(()), Err(error)) => Err(error), + } +} diff --git a/crates/gateway-stt-engine/src/test_fixtures.rs b/crates/gateway-stt-engine/src/test_fixtures.rs new file mode 100644 index 00000000..911dcc61 --- /dev/null +++ b/crates/gateway-stt-engine/src/test_fixtures.rs @@ -0,0 +1,201 @@ +//! Deterministic decoder fixtures for downstream integration tests. + +/// Native asset resolution for ignored integration tests. +pub mod native; + +use std::sync::mpsc::{TryRecvError, sync_channel}; +use std::time::{Duration, Instant}; + +use crate::{DecodeMode, Decoder, ModelFactory, TranscribeError}; + +mod scenarios; +pub use scenarios::ScriptedDecoder; + +struct ConstructionBlock(ScriptedDecoder); + +impl Drop for ConstructionBlock { + fn drop(&mut self) { + self.0.release_construction(); + } +} + +/// A role-specific scripted [`ModelFactory`] for test engines. +#[derive(Debug)] +pub struct ScriptedModelFactory { + interim: ScriptedDecoder, + final_decoder: Option, + interim_failure: Option, + final_failure: Option, + panic_interim: bool, + panic_final: bool, + gpu_available: bool, +} + +impl ScriptedModelFactory { + /// Creates an interim-only scripted factory. + #[must_use] + pub fn new(interim: ScriptedDecoder) -> Self { + Self { + interim, + final_decoder: None, + interim_failure: None, + final_failure: None, + panic_interim: false, + panic_final: false, + gpu_available: false, + } + } + + /// Installs the optional final-role decoder. + #[must_use] + pub fn with_final(mut self, decoder: ScriptedDecoder) -> Self { + self.final_decoder = Some(decoder); + self + } + + /// Makes interim construction fail with the supplied message. + #[must_use] + pub fn with_interim_failure(mut self, message: impl Into) -> Self { + self.interim_failure = Some(message.into()); + self + } + + /// Makes final construction fail with the supplied message. + #[must_use] + pub fn with_final_failure(mut self, message: impl Into) -> Self { + self.final_failure = Some(message.into()); + self + } + + /// Makes interim construction panic. + #[must_use] + pub fn with_interim_panic(mut self) -> Self { + self.panic_interim = true; + self + } + + /// Makes final construction panic. + #[must_use] + pub fn with_final_panic(mut self) -> Self { + self.panic_final = true; + self + } + + /// Sets the hardware-acceleration fact reported by the fixture. + #[must_use] + pub fn with_gpu_available(mut self, available: bool) -> Self { + self.gpu_available = available; + self + } + + /// Returns the fixture hardware-acceleration fact. + #[must_use] + pub fn gpu_available(&self) -> bool { + self.gpu_available + } + + /// Runs a bounded scenario while all configured decoders are constructing. + /// + /// Construction starts on a scoped thread. The scenario runs only after + /// every role is parked and before a result is available. The result must + /// then arrive within `result_timeout`; every return or unwind releases all + /// parked roles before joining the construction thread. + /// + /// # Panics + /// Panics when construction or the scenario panics, or when construction + /// completes before every configured role is observed parked. + pub fn with_construction_blocked( + self, + rendezvous_timeout: Duration, + result_timeout: Duration, + start: Start, + while_blocked: Scenario, + ) -> Option<(Result, Observation)> + where + Start: FnOnce(Self) -> Result + Send, + Result: Send, + Scenario: FnOnce() -> Observation, + { + let decoders = self.construction_decoders(); + for decoder in &decoders { + decoder.arm_construction(); + } + + std::thread::scope(|scope| { + let blocks = decoders + .iter() + .cloned() + .map(ConstructionBlock) + .collect::>(); + let (result_tx, result_rx) = sync_channel(1); + let constructor = scope.spawn(move || { + drop(result_tx.send(start(self))); + }); + + if !wait_until_all_constructing(&decoders, rendezvous_timeout) { + drop(blocks); + constructor + .join() + .unwrap_or_else(|panic| std::panic::resume_unwind(panic)); + return None; + } + assert!( + matches!(result_rx.try_recv(), Err(TryRecvError::Empty)), + "construction completed before every configured role was observed parked" + ); + + let observation = while_blocked(); + let result = result_rx.recv_timeout(result_timeout).ok(); + drop(blocks); + constructor + .join() + .unwrap_or_else(|panic| std::panic::resume_unwind(panic)); + result.map(|result| (result, observation)) + }) + } + + fn construction_decoders(&self) -> Vec { + let mut decoders = vec![self.interim.clone()]; + decoders.extend(self.final_decoder.iter().cloned()); + decoders + } +} + +impl ModelFactory for ScriptedModelFactory { + fn create(&self, mode: DecodeMode) -> Result>, TranscribeError> { + let (decoder, failure, panic) = match mode { + DecodeMode::Interim => ( + Some(&self.interim), + &self.interim_failure, + self.panic_interim, + ), + DecodeMode::Final => ( + self.final_decoder.as_ref(), + &self.final_failure, + self.panic_final, + ), + }; + assert!(!panic, "scripted {mode:?} factory panic"); + if let Some(message) = failure { + return Err(TranscribeError::InvalidConfig(message.clone())); + } + let Some(decoder) = decoder else { + return Ok(None); + }; + if let Some(message) = decoder.take_construction_error() { + return Err(TranscribeError::InvalidConfig(message)); + } + decoder.mark_created(); + Ok(Some(decoder.worker())) + } +} + +fn wait_until_all_constructing(decoders: &[ScriptedDecoder], timeout: Duration) -> bool { + let started = Instant::now(); + decoders.iter().all(|decoder| { + decoder.wait_until_construction_parked(timeout.saturating_sub(started.elapsed())) + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/gateway-stt-engine/src/test_fixtures/native.rs b/crates/gateway-stt-engine/src/test_fixtures/native.rs new file mode 100644 index 00000000..58d35480 --- /dev/null +++ b/crates/gateway-stt-engine/src/test_fixtures/native.rs @@ -0,0 +1,24 @@ +//! Native asset resolution shared by ignored STT tests. + +use std::path::{Path, PathBuf}; + +/// Resolves a required native fixture from an environment override or caller fallback root. +/// +/// # Panics +/// +/// Panics with the resolved path when the fixture is not a file. +#[must_use] +pub fn require_fixture( + environment_variable: &str, + fallback_root: &Path, + fallback_name: &str, +) -> PathBuf { + let path = std::env::var_os(environment_variable) + .map_or_else(|| fallback_root.join(fallback_name), PathBuf::from); + assert!( + path.is_file(), + "native test fixture is missing: {}", + path.display() + ); + path +} diff --git a/crates/gateway-stt-engine/src/test_fixtures/scenarios.rs b/crates/gateway-stt-engine/src/test_fixtures/scenarios.rs new file mode 100644 index 00000000..92d4400a --- /dev/null +++ b/crates/gateway-stt-engine/src/test_fixtures/scenarios.rs @@ -0,0 +1,294 @@ +use std::collections::VecDeque; +use std::future::Future; +use std::sync::{Arc, Condvar, Mutex, PoisonError}; +use std::thread::ThreadId; +use std::time::Duration; + +use crate::{DecodeRequest, Decoder, TranscribeError}; + +#[derive(Debug)] +enum ScriptedOutcome { + Text(String), + Error(String), + Panic, +} + +#[derive(Debug, Default, Eq, PartialEq)] +enum ParkState { + #[default] + Ready, + Armed, + Parked, + Released, +} + +#[derive(Debug, Default, Eq, PartialEq)] +enum ConstructionState { + #[default] + Ready, + Armed, + Parked, + Released, +} + +#[derive(Debug, Default)] +struct DecoderState { + outcomes: VecDeque, + construction_errors: VecDeque, + requests: Vec, + completed: usize, + creation_thread: Option, + decode_threads: Vec, + waiters: usize, + park: ParkState, + construction: ConstructionState, + worker_dropped: bool, + panic_on_drop: bool, +} + +/// A cloneable controller for one deterministic decoder. +#[derive(Clone, Debug, Default)] +pub struct ScriptedDecoder { + shared: Arc<(Mutex, Condvar)>, +} + +struct DecodeBlock(ScriptedDecoder); + +impl Drop for DecodeBlock { + fn drop(&mut self) { + self.0.release_decode(); + } +} + +impl ScriptedDecoder { + /// Creates a decoder whose unscripted calls return an empty transcript. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Appends one successful decode result. + pub fn push_text(&self, text: impl Into) { + self.state() + .outcomes + .push_back(ScriptedOutcome::Text(text.into())); + } + + /// Appends one backend-neutral decode failure. + pub fn push_error(&self, message: impl Into) { + self.state() + .outcomes + .push_back(ScriptedOutcome::Error(message.into())); + } + + /// Makes the next decode panic on its owning worker. + pub fn panic_next(&self) { + self.state().outcomes.push_back(ScriptedOutcome::Panic); + } + + /// Makes the next construction attempt return the supplied failure. + pub fn fail_next_construction(&self, message: impl Into) { + self.state().construction_errors.push_back(message.into()); + } + + /// Makes dropping the worker-owned decoder panic. + pub fn panic_on_drop(&self) { + self.state().panic_on_drop = true; + } + + /// Waits until at least `count` requests have entered the decoder. + #[must_use] + pub fn wait_for_requests(&self, count: usize, timeout: Duration) -> bool { + self.wait_for(timeout, |state| state.requests.len() >= count) + } + + /// Waits until at least `count` scripted decodes have returned. + #[must_use] + pub fn wait_for_completed(&self, count: usize, timeout: Duration) -> bool { + self.wait_for(timeout, |state| state.completed >= count) + } + + /// Returns all captured stateless requests. + #[must_use] + pub fn requests(&self) -> Vec { + self.state().requests.clone() + } + + /// Returns the worker that constructed the decoder, if construction ran. + #[must_use] + pub fn creation_thread(&self) -> Option { + self.state().creation_thread + } + + /// Returns the worker thread observed by every decode. + #[must_use] + pub fn decode_threads(&self) -> Vec { + self.state().decode_threads.clone() + } + + /// Whether engine cleanup dropped the worker-owned decoder. + #[must_use] + pub fn worker_dropped(&self) -> bool { + self.state().worker_dropped + } + + /// Waits until engine cleanup drops the worker-owned decoder. + #[must_use] + pub fn wait_until_worker_dropped(&self, timeout: Duration) -> bool { + self.wait_for(timeout, |state| state.worker_dropped) + } + + /// Runs an asynchronous scenario while the next decode is blocked. + /// + /// `start` must initiate the decode without awaiting its result. After the + /// decode enters the fixture, `while_blocked` runs and the decoder is + /// released when that future returns, is canceled, times out, or unwinds. + #[must_use] + pub async fn with_next_decode_blocked( + &self, + timeout: Duration, + start: Start, + while_blocked: Scenario, + ) -> Option + where + Start: FnOnce() -> Started, + Started: Future, + Scenario: FnOnce(Context) -> Running, + Running: Future, + { + self.state().park = ParkState::Armed; + let block = DecodeBlock(self.clone()); + let context = start().await; + let observer = self.clone(); + let parked = tokio::task::spawn_blocking(move || { + observer.wait_for(timeout, |state| state.park == ParkState::Parked) + }) + .await + .ok()?; + if !parked { + return None; + } + let output = while_blocked(context).await; + drop(block); + Some(output) + } + + pub(super) fn arm_construction(&self) { + self.state().construction = ConstructionState::Armed; + } + + pub(super) fn release_construction(&self) { + let (_, changed) = &*self.shared; + self.state().construction = ConstructionState::Released; + changed.notify_all(); + } + + pub(super) fn wait_until_construction_parked(&self, timeout: Duration) -> bool { + self.wait_for(timeout, |state| { + state.construction == ConstructionState::Parked + }) + } + + #[cfg(test)] + pub(super) fn wait_until_waiter_registered(&self, timeout: Duration) -> bool { + let (state, changed) = &*self.shared; + let state = state.lock().unwrap_or_else(PoisonError::into_inner); + let (state, result) = changed + .wait_timeout_while(state, timeout, |state| state.waiters == 0) + .unwrap_or_else(PoisonError::into_inner); + !result.timed_out() && state.waiters == 1 + } + + pub(super) fn take_construction_error(&self) -> Option { + self.state().construction_errors.pop_front() + } + + pub(super) fn mark_created(&self) { + let (state, changed) = &*self.shared; + let mut state = state.lock().unwrap_or_else(PoisonError::into_inner); + if state.construction == ConstructionState::Armed { + state.construction = ConstructionState::Parked; + changed.notify_all(); + state = changed + .wait_while(state, |state| { + state.construction != ConstructionState::Released + }) + .unwrap_or_else(PoisonError::into_inner); + state.construction = ConstructionState::Ready; + } + state.creation_thread = Some(std::thread::current().id()); + } + + pub(super) fn worker(&self) -> Box { + Box::new(WorkerDecoder(self.clone())) + } + + fn release_decode(&self) { + let (_, changed) = &*self.shared; + self.state().park = ParkState::Released; + changed.notify_all(); + } + + fn state(&self) -> std::sync::MutexGuard<'_, DecoderState> { + self.shared.0.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn wait_for(&self, timeout: Duration, predicate: impl Fn(&DecoderState) -> bool) -> bool { + let (state, changed) = &*self.shared; + let mut state = state.lock().unwrap_or_else(PoisonError::into_inner); + if predicate(&state) { + return true; + } + state.waiters += 1; + changed.notify_all(); + let (mut state, result) = changed + .wait_timeout_while(state, timeout, |state| !predicate(state)) + .unwrap_or_else(PoisonError::into_inner); + state.waiters -= 1; + !result.timed_out() && predicate(&state) + } +} + +struct WorkerDecoder(ScriptedDecoder); + +impl Decoder for WorkerDecoder { + fn decode(&mut self, request: DecodeRequest) -> Result { + let (state, changed) = &*self.0.shared; + let mut state = state.lock().unwrap_or_else(PoisonError::into_inner); + state.requests.push(request); + state.decode_threads.push(std::thread::current().id()); + changed.notify_all(); + if state.park == ParkState::Armed { + state.park = ParkState::Parked; + changed.notify_all(); + state = changed + .wait_while(state, |state| state.park != ParkState::Released) + .unwrap_or_else(PoisonError::into_inner); + state.park = ParkState::Ready; + } + let outcome = match state.outcomes.pop_front() { + Some(ScriptedOutcome::Text(text)) => Ok(text), + Some(ScriptedOutcome::Error(message)) => { + Err(TranscribeError::inference(std::io::Error::other(message))) + } + Some(ScriptedOutcome::Panic) => panic!("scripted decoder panic"), + None => Ok(String::new()), + }; + state.completed += 1; + changed.notify_all(); + outcome + } +} + +impl Drop for WorkerDecoder { + fn drop(&mut self) { + let (_, changed) = &*self.0.shared; + let panic_on_drop = { + let mut state = self.0.state(); + state.worker_dropped = true; + state.panic_on_drop + }; + changed.notify_all(); + assert!(!panic_on_drop, "scripted decoder drop panic"); + } +} diff --git a/crates/gateway-stt-engine/src/test_fixtures/tests.rs b/crates/gateway-stt-engine/src/test_fixtures/tests.rs new file mode 100644 index 00000000..348a0ae0 --- /dev/null +++ b/crates/gateway-stt-engine/src/test_fixtures/tests.rs @@ -0,0 +1,246 @@ +use super::*; +use crate::{DecodeRequest, EnginePolicy, SttEngine}; + +mod scenario_cleanup; + +fn policy() -> EnginePolicy { + EnginePolicy::new(15, 500, false).expect("test policy is valid") +} + +fn request( + mode: DecodeMode, + samples: Vec, + guidance: Vec, + finalized: impl Into, +) -> DecodeRequest { + DecodeRequest::new(mode, samples, guidance, finalized.into()) +} + +fn assert_invalid_config(error: TranscribeError, expected: &str) { + let TranscribeError::InvalidConfig(message) = error else { + panic!("expected invalid configuration, got {error}"); + }; + assert_eq!(message, expected); +} + +fn wait_until_waiter_is_registered(decoder: &ScriptedDecoder) { + assert!( + decoder.wait_until_waiter_registered(Duration::from_secs(1)), + "request waiter must enter the condition-variable wait" + ); +} + +#[tokio::test] +async fn scripted_roles_capture_requests_on_their_creation_threads() { + let caller = std::thread::current().id(); + let interim = ScriptedDecoder::new(); + interim.push_text("interim"); + let final_decoder = ScriptedDecoder::new(); + final_decoder.push_text("final"); + let engine = SttEngine::new( + ScriptedModelFactory::new(interim.clone()) + .with_final(final_decoder.clone()) + .with_gpu_available(true), + EnginePolicy::new(15, 500, true).expect("test policy is valid"), + ) + .expect("scripted workers start"); + + assert_eq!( + engine + .decode(request( + DecodeMode::Interim, + vec![0.25], + vec!["term".to_owned()], + "", + )) + .await + .expect("interim succeeds"), + "interim" + ); + assert_eq!( + engine + .decode(request( + DecodeMode::Final, + vec![0.5], + vec!["name".to_owned()], + "history", + )) + .await + .expect("final succeeds"), + "final" + ); + assert!(engine.gpu_transcription_available()); + let interim_requests = interim.requests(); + assert_eq!(interim_requests.len(), 1); + assert_eq!(interim_requests[0].mode(), DecodeMode::Interim); + assert_eq!(interim_requests[0].samples(), &[0.25]); + assert_eq!(interim_requests[0].guidance(), ["term"]); + assert_eq!(interim_requests[0].finalized(), ""); + let final_requests = final_decoder.requests(); + assert_eq!(final_requests.len(), 1); + assert_eq!(final_requests[0].mode(), DecodeMode::Final); + assert_eq!(final_requests[0].samples(), &[0.5]); + assert_eq!(final_requests[0].guidance(), ["name"]); + assert_eq!(final_requests[0].finalized(), "history"); + assert_ne!(interim.creation_thread(), Some(caller)); + assert_eq!( + interim.decode_threads(), + vec![interim.creation_thread().expect("interim was constructed")] + ); + assert_eq!( + final_decoder.decode_threads(), + vec![ + final_decoder + .creation_thread() + .expect("final was constructed") + ] + ); + engine.shutdown().expect("workers join"); + assert!(interim.worker_dropped()); + assert!(final_decoder.worker_dropped()); +} + +#[test] +fn scripted_interim_startup_panic_is_explicit_without_a_decoder() { + let interim = ScriptedDecoder::new(); + let error = SttEngine::new( + ScriptedModelFactory::new(interim.clone()).with_interim_panic(), + policy(), + ) + .expect_err("startup panic fails construction"); + assert!(matches!(error, TranscribeError::WorkerPanicked)); + assert_eq!(interim.creation_thread(), None); + assert!(!interim.worker_dropped()); +} + +#[test] +fn scripted_final_startup_panic_is_explicit_and_cleans_up_interim() { + let interim = ScriptedDecoder::new(); + let error = SttEngine::new( + ScriptedModelFactory::new(interim.clone()).with_final_panic(), + policy(), + ) + .expect_err("startup panic fails construction"); + assert!(matches!(error, TranscribeError::WorkerPanicked)); + assert!(interim.worker_dropped()); +} + +#[tokio::test] +async fn scripted_decode_panic_is_explicit_and_closes_the_worker() { + let interim = ScriptedDecoder::new(); + interim.panic_next(); + let engine = SttEngine::new(ScriptedModelFactory::new(interim), policy()) + .expect("scripted worker starts"); + let first = engine + .decode(request(DecodeMode::Interim, Vec::new(), Vec::new(), "")) + .await + .expect_err("panic is reported"); + assert!(matches!(first, TranscribeError::WorkerPanicked)); + let second = engine + .decode(request(DecodeMode::Interim, Vec::new(), Vec::new(), "")) + .await + .expect_err("panicked worker stays closed"); + assert!(matches!(second, TranscribeError::WorkerGone)); +} + +#[tokio::test] +async fn request_waiter_started_before_an_unparked_decode_is_notified() { + let interim = ScriptedDecoder::new(); + let waiter_decoder = interim.clone(); + let waiter = + std::thread::spawn(move || waiter_decoder.wait_for_requests(1, Duration::from_secs(1))); + wait_until_waiter_is_registered(&interim); + let engine = SttEngine::new(ScriptedModelFactory::new(interim.clone()), policy()) + .expect("scripted worker starts"); + + engine + .decode(request( + DecodeMode::Interim, + vec![0.25], + vec!["term".to_owned()], + "", + )) + .await + .expect("unparked decode succeeds"); + assert!( + waiter.join().expect("request waiter does not panic"), + "recording the request wakes the pre-existing waiter" + ); + engine.shutdown().expect("worker joins"); + assert!(interim.worker_dropped()); +} + +#[tokio::test] +async fn scripted_decode_error_reaches_the_caller_and_cleanup_drops_the_worker() { + const SENTINEL: &str = "scripted decode sentinel"; + + let interim = ScriptedDecoder::new(); + interim.push_error(SENTINEL); + let engine = SttEngine::new(ScriptedModelFactory::new(interim.clone()), policy()) + .expect("scripted worker starts"); + let error = engine + .decode(request(DecodeMode::Interim, vec![0.25], Vec::new(), "")) + .await + .expect_err("scripted decode fails"); + let TranscribeError::Inference(source) = error else { + panic!("expected inference failure, got {error}"); + }; + assert_eq!(source.to_string(), SENTINEL); + assert!(source.source().is_none()); + + engine.shutdown().expect("worker joins"); + assert!(interim.worker_dropped()); +} + +#[test] +fn scripted_interim_factory_error_reaches_the_constructor_without_a_decoder() { + const SENTINEL: &str = "scripted interim startup sentinel"; + + let interim = ScriptedDecoder::new(); + let error = SttEngine::new( + ScriptedModelFactory::new(interim.clone()).with_interim_failure(SENTINEL), + policy(), + ) + .expect_err("scripted interim construction fails"); + assert_invalid_config(error, SENTINEL); + assert_eq!(interim.creation_thread(), None); + assert!(!interim.worker_dropped()); +} + +#[test] +fn scripted_final_factory_error_reaches_the_constructor_and_cleans_up_interim() { + const SENTINEL: &str = "scripted final startup sentinel"; + + let interim = ScriptedDecoder::new(); + let final_decoder = ScriptedDecoder::new(); + let error = SttEngine::new( + ScriptedModelFactory::new(interim.clone()) + .with_final(final_decoder.clone()) + .with_final_failure(SENTINEL), + policy(), + ) + .expect_err("scripted final construction fails"); + assert_invalid_config(error, SENTINEL); + assert!(interim.creation_thread().is_some()); + assert!(interim.worker_dropped()); + assert_eq!(final_decoder.creation_thread(), None); + assert!(!final_decoder.worker_dropped()); +} + +#[test] +fn shutdown_surfaces_join_panic_and_remains_idempotent() { + let interim = ScriptedDecoder::new(); + interim.panic_on_drop(); + let engine = SttEngine::new(ScriptedModelFactory::new(interim.clone()), policy()) + .expect("scripted worker starts"); + + assert!(matches!( + engine.shutdown(), + Err(TranscribeError::ShutdownPanicked) + )); + assert!(matches!( + engine.shutdown(), + Err(TranscribeError::ShutdownPanicked) + )); + assert!(interim.worker_dropped()); +} 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 new file mode 100644 index 00000000..dff722a2 --- /dev/null +++ b/crates/gateway-stt-engine/src/test_fixtures/tests/scenario_cleanup.rs @@ -0,0 +1,56 @@ +use std::sync::Arc; + +use super::*; + +mod construction; +mod decode; + +const WAIT: Duration = Duration::from_secs(1); + +async fn run_blocked_decode(decoder: &ScriptedDecoder, engine: &Arc, transcript: &str) { + decoder.push_text(transcript); + let decode = decoder + .with_next_decode_blocked( + WAIT, + || { + let engine = Arc::clone(engine); + async move { + (tokio::spawn(async move { + engine + .decode(request(DecodeMode::Interim, vec![0.25], Vec::new(), "")) + .await + }),) + } + }, + |(decode,)| async { (decode,) }, + ) + .await + .expect("follow-up decode reaches the blocked scenario"); + let (decode,) = decode; + assert_eq!( + tokio::time::timeout(WAIT, decode) + .await + .expect("released decode completes") + .expect("decode task joins") + .expect("scripted decode succeeds"), + transcript + ); +} + +fn run_timed_out_construction(decoder: &ScriptedDecoder) { + let factory = ScriptedModelFactory::new(decoder.clone()); + let timeout = policy().with_startup_timeout(Duration::from_millis(20)); + let (result, ()) = factory + .with_construction_blocked( + WAIT, + WAIT, + |factory| SttEngine::new(factory, timeout), + || (), + ) + .expect("construction reaches the blocked scenario and its bounded result"); + assert!(matches!( + result.expect_err("parked construction times out"), + TranscribeError::InterimStartupTimedOut + )); + assert!(decoder.wait_until_worker_dropped(WAIT)); +} 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 new file mode 100644 index 00000000..75adb1a8 --- /dev/null +++ b/crates/gateway-stt-engine/src/test_fixtures/tests/scenario_cleanup/construction.rs @@ -0,0 +1,100 @@ +use std::panic::{AssertUnwindSafe, catch_unwind}; + +use super::*; + +#[test] +fn parked_construction_has_a_bounded_classified_outcome() { + let decoder = ScriptedDecoder::new(); + run_timed_out_construction(&decoder); +} + +#[test] +fn construction_rendezvous_timeout_releases_a_late_arrival_and_permits_a_follow_up() { + let decoder = ScriptedDecoder::new(); + let factory = ScriptedModelFactory::new(decoder.clone()); + let result = factory.with_construction_blocked( + Duration::from_millis(10), + WAIT, + |factory| { + std::thread::sleep(Duration::from_millis(50)); + SttEngine::new(factory, policy()) + }, + || (), + ); + assert!(result.is_none(), "the rendezvous must time out"); + assert!( + decoder.wait_until_worker_dropped(WAIT), + "the late constructor is released and its discarded engine shuts down" + ); + run_timed_out_construction(&decoder); +} + +#[test] +fn construction_result_timeout_releases_and_permits_a_follow_up() { + let decoder = ScriptedDecoder::new(); + let factory = ScriptedModelFactory::new(decoder.clone()); + let result = factory.with_construction_blocked( + WAIT, + Duration::from_millis(10), + |factory| SttEngine::new(factory, policy()), + || (), + ); + assert!(result.is_none(), "the bounded result wait must time out"); + assert!( + decoder.wait_until_worker_dropped(WAIT), + "releasing construction lets the discarded engine shut down" + ); + run_timed_out_construction(&decoder); +} + +#[test] +fn panicked_construction_scenario_releases_and_permits_a_follow_up() { + let decoder = ScriptedDecoder::new(); + let factory = ScriptedModelFactory::new(decoder.clone()); + let panic = catch_unwind(AssertUnwindSafe(|| { + factory.with_construction_blocked( + WAIT, + WAIT, + |factory| SttEngine::new(factory, policy()), + || panic!("construction scenario panic sentinel"), + ) + })); + assert!(panic.is_err(), "the scenario panic must propagate"); + assert!( + decoder.wait_until_worker_dropped(WAIT), + "unwinding releases construction and shuts down the discarded engine" + ); + run_timed_out_construction(&decoder); +} + +#[test] +fn parked_final_construction_cleans_up_the_initialized_interim_worker() { + let interim = ScriptedDecoder::new(); + let final_decoder = ScriptedDecoder::new(); + final_decoder.arm_construction(); + let factory = ScriptedModelFactory::new(interim.clone()).with_final(final_decoder.clone()); + let timeout = policy().with_startup_timeout(Duration::from_millis(20)); + let (result_tx, result_rx) = std::sync::mpsc::channel(); + let constructor = std::thread::spawn(move || { + drop(result_tx.send(SttEngine::new(factory, timeout))); + }); + assert!( + final_decoder.wait_until_construction_parked(WAIT), + "final construction reaches its deterministic park" + ); + let error = result_rx + .recv_timeout(WAIT) + .expect("startup returns by its deadline") + .expect_err("parked final construction times out"); + assert!(matches!(error, TranscribeError::FinalStartupTimedOut)); + assert!( + interim.worker_dropped(), + "the worker initialized first is joined and cleaned up" + ); + constructor.join().expect("constructor does not panic"); + final_decoder.release_construction(); + assert!( + final_decoder.wait_until_worker_dropped(WAIT), + "the abandoned constructor releases its decoder after returning" + ); +} 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 new file mode 100644 index 00000000..265b91f9 --- /dev/null +++ b/crates/gateway-stt-engine/src/test_fixtures/tests/scenario_cleanup/decode.rs @@ -0,0 +1,150 @@ +use super::*; + +fn start_decode( + engine: Arc, +) -> tokio::task::JoinHandle> { + tokio::spawn(async move { + engine + .decode(request(DecodeMode::Interim, vec![0.25], Vec::new(), "")) + .await + }) +} + +#[tokio::test] +async fn blocked_decode_scenario_releases_after_normal_return() { + let decoder = ScriptedDecoder::new(); + let engine = Arc::new( + SttEngine::new(ScriptedModelFactory::new(decoder.clone()), policy()) + .expect("scripted worker starts"), + ); + run_blocked_decode(&decoder, &engine, "released").await; + engine.shutdown().expect("worker joins"); +} + +#[tokio::test] +async fn canceled_decode_scenario_releases_and_permits_a_follow_up() { + let decoder = ScriptedDecoder::new(); + decoder.push_text("released after cancellation"); + let engine = Arc::new( + SttEngine::new(ScriptedModelFactory::new(decoder.clone()), policy()) + .expect("scripted worker starts"), + ); + let scenario_decoder = decoder.clone(); + let scenario_engine = Arc::clone(&engine); + let (decode_tx, decode_rx) = tokio::sync::oneshot::channel(); + let (parked_tx, parked_rx) = tokio::sync::oneshot::channel(); + let scenario = tokio::spawn(async move { + scenario_decoder + .with_next_decode_blocked( + WAIT, + || async move { + drop(decode_tx.send(start_decode(scenario_engine))); + }, + |()| async move { + let _ = parked_tx.send(()); + std::future::pending::<()>().await; + }, + ) + .await + }); + + tokio::time::timeout(WAIT, parked_rx) + .await + .expect("decode parks before cancellation") + .expect("park observer remains live"); + scenario.abort(); + assert!( + scenario + .await + .expect_err("scenario is canceled") + .is_cancelled() + ); + assert_eq!( + tokio::time::timeout(WAIT, decode_rx.await.expect("decode handle is published")) + .await + .expect("cancellation releases the decode") + .expect("decode task joins") + .expect("released decode succeeds"), + "released after cancellation" + ); + run_blocked_decode(&decoder, &engine, "follow-up after cancellation").await; + engine.shutdown().expect("worker joins"); +} + +#[tokio::test] +async fn decode_rendezvous_timeout_releases_a_late_arrival_and_permits_a_follow_up() { + let decoder = ScriptedDecoder::new(); + decoder.push_text("late arrival"); + let engine = Arc::new( + SttEngine::new(ScriptedModelFactory::new(decoder.clone()), policy()) + .expect("scripted worker starts"), + ); + let delayed_engine = Arc::clone(&engine); + let (decode_tx, decode_rx) = tokio::sync::oneshot::channel(); + let result = decoder + .with_next_decode_blocked( + Duration::from_millis(10), + || async move { + let decode = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + start_decode(delayed_engine) + .await + .expect("nested decode task joins") + }); + drop(decode_tx.send(decode)); + }, + |()| async {}, + ) + .await; + assert!(result.is_none(), "the rendezvous must time out"); + assert_eq!( + tokio::time::timeout( + WAIT, + decode_rx.await.expect("late decode handle is published") + ) + .await + .expect("late decode is not stranded") + .expect("late decode task joins") + .expect("late decode succeeds"), + "late arrival" + ); + run_blocked_decode(&decoder, &engine, "follow-up after timeout").await; + engine.shutdown().expect("worker joins"); +} + +#[tokio::test] +async fn panicked_decode_scenario_releases_and_permits_a_follow_up() { + let decoder = ScriptedDecoder::new(); + decoder.push_text("released after panic"); + let engine = Arc::new( + SttEngine::new(ScriptedModelFactory::new(decoder.clone()), policy()) + .expect("scripted worker starts"), + ); + let scenario_decoder = decoder.clone(); + let scenario_engine = Arc::clone(&engine); + let (decode_tx, decode_rx) = tokio::sync::oneshot::channel(); + let scenario = tokio::spawn(async move { + scenario_decoder + .with_next_decode_blocked( + WAIT, + || async move { + drop(decode_tx.send(start_decode(scenario_engine))); + }, + |()| async move { + panic!("decode scenario panic sentinel"); + }, + ) + .await + }); + assert!(scenario.await.expect_err("scenario panics").is_panic()); + assert_eq!( + tokio::time::timeout(WAIT, decode_rx.await.expect("decode handle is published")) + .await + .expect("unwind releases the decode") + .expect("decode task joins") + .expect("released decode succeeds"), + "released after panic" + ); + run_blocked_decode(&decoder, &engine, "follow-up after panic").await; + engine.shutdown().expect("worker joins"); +} diff --git a/crates/gateway-stt-engine/src/translation.rs b/crates/gateway-stt-engine/src/translation.rs new file mode 100644 index 00000000..e3fb2c72 --- /dev/null +++ b/crates/gateway-stt-engine/src/translation.rs @@ -0,0 +1,50 @@ +//! Backend failure translation into engine-owned errors. + +use std::path::PathBuf; + +use crate::TranscribeError; + +impl TranscribeError { + /// Returns whether startup exceeded a deadline and abandoned at least one + /// non-preemptible worker construction call. + #[must_use] + pub fn is_non_preemptible_startup_timeout(&self) -> bool { + match self { + Self::InterimStartupTimedOut | Self::FinalStartupTimedOut => true, + Self::StartupFailures { failures, .. } => failures + .iter() + .any(Self::is_non_preemptible_startup_timeout), + Self::StartupCleanup { + startup, cleanup, .. + } => { + startup.is_non_preemptible_startup_timeout() + || cleanup.iter().any(Self::is_non_preemptible_startup_timeout) + } + Self::ShutdownFailures { cleanup, .. } => { + cleanup.iter().any(Self::is_non_preemptible_startup_timeout) + } + _ => false, + } + } + + /// Translates a backend initialization source. + pub fn initialize_backend(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::InitializeBackend(Box::new(source)) + } + + /// Translates a model construction source while preserving its path. + pub fn load_model( + path: PathBuf, + source: impl std::error::Error + Send + Sync + 'static, + ) -> Self { + Self::LoadModel { + path, + source: Box::new(source), + } + } + + /// Translates a backend inference source. + pub fn inference(source: impl std::error::Error + Send + Sync + 'static) -> Self { + Self::Inference(Box::new(source)) + } +} diff --git a/crates/gateway-stt-engine/src/worker.rs b/crates/gateway-stt-engine/src/worker.rs new file mode 100644 index 00000000..d35e87d6 --- /dev/null +++ b/crates/gateway-stt-engine/src/worker.rs @@ -0,0 +1,460 @@ +//! One backend-neutral transcription worker. + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, PoisonError, mpsc}; + +use crate::{DecodeMode, DecodeRequest, Decoder, ModelFactory, TranscribeError}; + +pub(crate) const INTERIM_JOB_CAPACITY: usize = 8; +pub(crate) const FINAL_JOB_CAPACITY: usize = 8; + +struct Job { + request: DecodeRequest, + reply: tokio::sync::oneshot::Sender>, +} + +/// Handle to a decoder confined to its worker thread. +#[derive(Debug)] +pub(crate) struct Transcriber { + state: Mutex, + stopping: Arc, +} + +#[derive(Debug)] +struct TranscriberState { + job_tx: Option>, + worker: Option>, + join_panicked: bool, +} + +impl Transcriber { + /// Spawns one worker and reports whether its optional decoder exists. + pub(super) fn spawn( + name: &'static str, + factory: Arc, + mode: DecodeMode, + capacity: usize, + ) -> Result<(Self, mpsc::Receiver>), TranscribeError> { + let (job_tx, job_rx) = mpsc::sync_channel::(capacity); + let (init_tx, init_rx) = mpsc::sync_channel(1); + let stopping = Arc::new(AtomicBool::new(false)); + let worker_stopping = Arc::clone(&stopping); + let worker = std::thread::Builder::new() + .name(name.to_owned()) + .spawn(move || { + worker_loop(factory.as_ref(), mode, &job_rx, &init_tx, &worker_stopping); + }) + .map_err(TranscribeError::SpawnWorker)?; + Ok(( + Self { + state: Mutex::new(TranscriberState { + job_tx: Some(job_tx), + worker: Some(worker), + join_panicked: false, + }), + stopping, + }, + init_rx, + )) + } + + fn submit( + &self, + request: DecodeRequest, + ) -> Result>, TranscribeError> + { + let (reply, reply_rx) = tokio::sync::oneshot::channel(); + let state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + let Some(job_tx) = &state.job_tx else { + return Err(TranscribeError::WorkerGone); + }; + job_tx + .try_send(Job { request, reply }) + .map_err(|error| match error { + mpsc::TrySendError::Full(_) => TranscribeError::Overloaded, + mpsc::TrySendError::Disconnected(_) => TranscribeError::WorkerGone, + })?; + Ok(reply_rx) + } + + pub(super) async fn transcribe( + &self, + request: DecodeRequest, + ) -> Result { + let reply_rx = self.submit(request)?; + reply_rx.await.map_err(|_| TranscribeError::WorkerGone)? + } + + pub(super) fn shutdown(&self) -> Result<(), TranscribeError> { + self.stopping.store(true, Ordering::Release); + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + drop(state.job_tx.take()); + if let Some(worker) = state.worker.take() { + state.join_panicked = worker.join().is_err(); + } + if state.join_panicked { + Err(TranscribeError::ShutdownPanicked) + } else { + Ok(()) + } + } + + pub(super) fn abandon_startup(&self) { + self.stopping.store(true, Ordering::Release); + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + drop(state.job_tx.take()); + // Construction is non-preemptible. Dropping this handle explicitly + // abandons only a timed-out startup worker so the host can classify + // the fatal outcome without claiming the thread was stopped. + drop(state.worker.take()); + } + + pub(super) fn startup_failure( + startup: TranscribeError, + cleanup: impl IntoIterator>, + ) -> TranscribeError { + let cleanup = cleanup + .into_iter() + .filter_map(Result::err) + .collect::>(); + if cleanup.is_empty() { + startup + } else { + TranscribeError::StartupCleanup { + startup: Box::new(startup), + cleanup, + } + } + } +} + +impl Drop for Transcriber { + fn drop(&mut self) { + drop(self.shutdown()); + } +} + +fn worker_loop( + factory: &dyn ModelFactory, + mode: DecodeMode, + job_rx: &mpsc::Receiver, + init_tx: &mpsc::SyncSender>, + stopping: &AtomicBool, +) { + let decoder = catch_unwind(AssertUnwindSafe(|| factory.create(mode))); + let decoder = match decoder { + Ok(result) => result, + Err(_) => Err(TranscribeError::WorkerPanicked), + }; + let Some(mut decoder): Option> = (match decoder { + Ok(decoder) => { + if init_tx.send(Ok(decoder.is_some())).is_err() { + return; + } + decoder + } + Err(error) => { + // Initialization is terminal; cancellation leaves no constructor to receive it. + drop(init_tx.send(Err(error))); + return; + } + }) else { + return; + }; + while !stopping.load(Ordering::Acquire) { + let Ok(job) = job_rx.recv() else { + return; + }; + if stopping.load(Ordering::Acquire) { + return; + } + if job.reply.is_closed() { + continue; + } + let result = catch_unwind(AssertUnwindSafe(|| decoder.decode(job.request))); + if let Ok(result) = result { + if !stopping.load(Ordering::Acquire) { + // A disconnected caller no longer needs this stateless result. + drop(job.reply.send(result)); + } + } else { + // A disconnected caller cannot make the panicked worker reusable. + drop(job.reply.send(Err(TranscribeError::WorkerPanicked))); + return; + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Condvar, Mutex}; + use std::time::Duration; + + use super::*; + + #[derive(Debug, Default, Eq, PartialEq)] + enum ParkPhase { + #[default] + Ready, + Entered, + Released, + Finished, + } + + #[derive(Debug, Default)] + struct ParkState { + calls: usize, + phase: ParkPhase, + dropped: bool, + } + + #[derive(Debug, Clone, Default)] + struct ParkControl { + state: Arc<(Mutex, Condvar)>, + } + + impl ParkControl { + fn wait_for(&self, predicate: impl Fn(&ParkState) -> bool, message: &str) { + let (state, changed) = &*self.state; + let guard = state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (guard, timeout) = changed + .wait_timeout_while(guard, Duration::from_secs(1), |state| !predicate(state)) + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert!(!timeout.timed_out() && predicate(&guard), "{message}"); + } + + fn release(&self) { + let (state, changed) = &*self.state; + state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .phase = ParkPhase::Released; + changed.notify_all(); + } + + fn calls(&self) -> usize { + self.state + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .calls + } + } + + #[derive(Debug)] + struct ParkFactory(ParkControl); + + impl ModelFactory for ParkFactory { + fn create( + &self, + _mode: DecodeMode, + ) -> Result>, TranscribeError> { + Ok(Some(Box::new(ParkDecoder(self.0.clone())))) + } + } + + struct ParkDecoder(ParkControl); + + impl crate::Decoder for ParkDecoder { + fn decode(&mut self, _request: DecodeRequest) -> Result { + let (state, changed) = &*self.0.state; + let mut state = state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + state.calls += 1; + if state.calls == 1 { + state.phase = ParkPhase::Entered; + changed.notify_all(); + state = changed + .wait_while(state, |state| state.phase != ParkPhase::Released) + .unwrap_or_else(std::sync::PoisonError::into_inner); + } + state.phase = ParkPhase::Finished; + changed.notify_all(); + Ok("scripted".to_owned()) + } + } + + impl Drop for ParkDecoder { + fn drop(&mut self) { + let (state, changed) = &*self.0.state; + state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .dropped = true; + changed.notify_all(); + } + } + + fn request(mode: DecodeMode) -> DecodeRequest { + DecodeRequest::new(mode, Vec::new(), Vec::new(), String::new()) + } + + fn parked_worker(mode: DecodeMode, capacity: usize) -> (Transcriber, ParkControl) { + let control = ParkControl::default(); + let factory: Arc = Arc::new(ParkFactory(control.clone())); + let (worker, startup) = Transcriber::spawn("bounded-worker-test", factory, mode, capacity) + .expect("worker spawns"); + assert!( + startup + .recv() + .expect("startup outcome arrives") + .expect("decoder starts") + ); + (worker, control) + } + + fn assert_queue_boundary(mode: DecodeMode, capacity: usize) { + let (worker, control) = parked_worker(mode, capacity); + let running = worker + .submit(request(mode)) + .expect("running job is admitted"); + control.wait_for( + |state| state.phase == ParkPhase::Entered, + "first job enters the decoder", + ); + + let queued = (0..capacity) + .map(|_| { + worker + .submit(request(mode)) + .expect("every queue slot is admitted") + }) + .collect::>(); + let error = worker + .submit(request(mode)) + .expect_err("capacity plus one must fail without waiting"); + assert!(matches!(error, TranscribeError::Overloaded)); + + drop(queued); + control.release(); + assert_eq!( + running + .blocking_recv() + .expect("worker replies") + .expect("decode succeeds"), + "scripted" + ); + worker.shutdown().expect("worker joins"); + assert_eq!(control.calls(), 1, "cancelled queued jobs never decode"); + } + + #[test] + fn interim_queue_accepts_exact_capacity_and_rejects_capacity_plus_one() { + assert_eq!(INTERIM_JOB_CAPACITY, 8); + assert_queue_boundary(DecodeMode::Interim, INTERIM_JOB_CAPACITY); + } + + #[test] + fn final_queue_accepts_exact_capacity_and_rejects_capacity_plus_one() { + assert_eq!(FINAL_JOB_CAPACITY, 8); + assert_queue_boundary(DecodeMode::Final, FINAL_JOB_CAPACITY); + } + + #[cfg(feature = "test-fixtures")] + #[test] + fn miri_worker_queues_own_exact_capacity_and_reject_the_next_job() { + assert_queue_boundary(DecodeMode::Interim, INTERIM_JOB_CAPACITY); + assert_queue_boundary(DecodeMode::Final, FINAL_JOB_CAPACITY); + } + + #[cfg(feature = "test-fixtures")] + #[test] + fn miri_shutdown_releases_worker_ownership_once() { + let (worker, control) = parked_worker(DecodeMode::Interim, INTERIM_JOB_CAPACITY); + worker.shutdown().expect("first shutdown joins"); + worker.shutdown().expect("second shutdown is idempotent"); + + assert!( + control + .state + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .dropped, + "joined shutdown releases the worker-owned decoder" + ); + } + + #[test] + fn cancellation_while_running_discards_only_that_reply() { + let (worker, control) = parked_worker(DecodeMode::Interim, INTERIM_JOB_CAPACITY); + let cancelled = worker + .submit(request(DecodeMode::Interim)) + .expect("running job is admitted"); + control.wait_for( + |state| state.phase == ParkPhase::Entered, + "job enters the decoder", + ); + drop(cancelled); + control.release(); + control.wait_for( + |state| state.phase == ParkPhase::Finished, + "cancelled native-equivalent work returns", + ); + + let next = worker + .submit(request(DecodeMode::Interim)) + .expect("worker remains available"); + assert_eq!( + next.blocking_recv() + .expect("worker replies") + .expect("decode succeeds"), + "scripted" + ); + worker.shutdown().expect("worker joins"); + assert_eq!(control.calls(), 2); + } + + #[test] + fn shutdown_joins_the_worker_and_is_idempotent() { + let (worker, control) = parked_worker(DecodeMode::Interim, INTERIM_JOB_CAPACITY); + worker.shutdown().expect("first shutdown joins"); + worker.shutdown().expect("second shutdown is idempotent"); + control.wait_for( + |state| state.dropped, + "shutdown drops the decoder before returning", + ); + assert!(worker.submit(request(DecodeMode::Interim)).is_err()); + } + + #[test] + fn shutdown_waits_for_running_decode_instead_of_detaching() { + let (worker, control) = parked_worker(DecodeMode::Interim, INTERIM_JOB_CAPACITY); + let reply = worker + .submit(request(DecodeMode::Interim)) + .expect("running job is admitted"); + control.wait_for( + |state| state.phase == ParkPhase::Entered, + "job enters the decoder", + ); + let stopping = Arc::clone(&worker.stopping); + let (returned_tx, returned_rx) = mpsc::channel(); + let shutdown = std::thread::spawn(move || { + worker.shutdown().expect("worker joins"); + let _ignored = returned_tx.send(()); + }); + + let deadline = std::time::Instant::now() + Duration::from_secs(1); + while !stopping.load(Ordering::Acquire) && std::time::Instant::now() < deadline { + std::thread::yield_now(); + } + assert!( + stopping.load(Ordering::Acquire), + "shutdown closes admission" + ); + assert!(matches!( + returned_rx.try_recv(), + Err(mpsc::TryRecvError::Empty) + )); + control.release(); + returned_rx + .recv_timeout(Duration::from_secs(1)) + .expect("shutdown returns after native-equivalent work"); + shutdown.join().expect("shutdown thread does not panic"); + assert!(reply.blocking_recv().is_err(), "shutdown cancels the reply"); + } +} diff --git a/crates/gateway-stt-engine/tests/engine_contract.rs b/crates/gateway-stt-engine/tests/engine_contract.rs new file mode 100644 index 00000000..72b2a71d --- /dev/null +++ b/crates/gateway-stt-engine/tests/engine_contract.rs @@ -0,0 +1,238 @@ +//! Public engine construction and decode regressions. + +use gateway_stt_engine::{ + DecodeMode, DecodeRequest, Decoder, EnginePolicy, ModelFactory, SttEngine, TranscribeError, +}; +use std::path::PathBuf; +use std::sync::mpsc; +use std::thread::ThreadId; +use std::time::Duration; +use thiserror as _; +use tokio as _; + +fn policy() -> EnginePolicy { + let Ok(policy) = EnginePolicy::new(15, 500, false) else { + panic!("test policy must be valid"); + }; + policy +} + +#[test] +fn zero_window_is_rejected_before_backend_construction() { + let error = EnginePolicy::new(0, 500, false).expect_err("zero window must fail"); + assert_eq!( + error.to_string(), + "invalid STT configuration: stt.window_seconds must be at least 1" + ); +} + +#[test] +fn zero_interval_is_rejected_before_backend_construction() { + let error = EnginePolicy::new(15, 0, false).expect_err("zero interval must fail"); + assert_eq!( + error.to_string(), + "invalid STT configuration: stt.interval_ms must be at least 1" + ); +} + +#[test] +fn oversized_startup_timeout_is_the_exact_typed_configuration_error() { + let (created, _created_rx) = mpsc::channel(); + let Err(error) = SttEngine::new( + FailingModelFactory { created }, + policy().with_startup_timeout(Duration::MAX), + ) else { + panic!("an unrepresentable absolute deadline must fail"); + }; + assert_eq!( + error.to_string(), + "invalid STT configuration: stt.startup_timeout is too large" + ); +} + +#[derive(Debug)] +struct FailingModelFactory { + created: mpsc::Sender, +} + +impl ModelFactory for FailingModelFactory { + fn create(&self, mode: DecodeMode) -> Result>, TranscribeError> { + if mode == DecodeMode::Final { + return Ok(None); + } + assert!( + self.created.send(std::thread::current().id()).is_ok(), + "the test must receive the worker identity" + ); + Err(TranscribeError::load_model( + PathBuf::from("failing-model.bin"), + std::io::Error::other("fake model construction failure"), + )) + } +} + +#[test] +fn model_initialization_failure_reaches_the_constructor_from_the_worker() { + let caller = std::thread::current().id(); + let (created_tx, created_rx) = mpsc::channel(); + let error = SttEngine::new( + FailingModelFactory { + created: created_tx, + }, + policy(), + ) + .expect_err("model construction must fail"); + assert_eq!( + error.to_string(), + "load transcription model failing-model.bin" + ); + let created = created_rx + .recv_timeout(Duration::from_secs(1)) + .expect("the factory records its owning thread"); + assert_ne!( + created, caller, + "model construction belongs on the dedicated worker" + ); +} + +const FINAL_INIT_SENTINEL: &str = "sentinel-final-initialization-failure"; + +#[derive(Debug)] +struct FinalFailingModelFactory { + interim_dropped: mpsc::Sender<()>, +} + +impl ModelFactory for FinalFailingModelFactory { + fn create(&self, mode: DecodeMode) -> Result>, TranscribeError> { + match mode { + DecodeMode::Interim => Ok(Some(Box::new(InterimDropProbe { + dropped: self.interim_dropped.clone(), + }))), + DecodeMode::Final => Err(TranscribeError::load_model( + PathBuf::from(FINAL_INIT_SENTINEL), + std::io::Error::other(FINAL_INIT_SENTINEL), + )), + } + } +} + +struct InterimDropProbe { + dropped: mpsc::Sender<()>, +} + +impl Decoder for InterimDropProbe { + fn decode(&mut self, _request: DecodeRequest) -> Result { + Ok(String::new()) + } +} + +impl Drop for InterimDropProbe { + fn drop(&mut self) { + let _ignored = self.dropped.send(()); + } +} + +#[test] +fn final_initialization_failure_propagates_and_cleans_up_the_interim_worker() { + let (dropped_tx, dropped_rx) = mpsc::channel(); + let error = SttEngine::new( + FinalFailingModelFactory { + interim_dropped: dropped_tx, + }, + policy(), + ) + .expect_err("final model construction must fail"); + assert_eq!( + error.to_string(), + format!("load transcription model {FINAL_INIT_SENTINEL}") + ); + dropped_rx + .recv_timeout(Duration::from_secs(1)) + .expect("constructor failure releases the initialized interim decoder"); +} + +#[derive(Debug)] +enum WorkerEvent { + Created(ThreadId), + Decoded { owner: ThreadId, current: ThreadId }, +} + +#[derive(Debug)] +struct FailingDecoderFactory { + events: mpsc::Sender, +} + +impl ModelFactory for FailingDecoderFactory { + fn create(&self, mode: DecodeMode) -> Result>, TranscribeError> { + if mode == DecodeMode::Final { + return Ok(None); + } + let owner = std::thread::current().id(); + assert!( + self.events.send(WorkerEvent::Created(owner)).is_ok(), + "the test must receive decoder creation" + ); + Ok(Some(Box::new(FailingDecoder { + owner, + events: self.events.clone(), + }))) + } +} + +struct FailingDecoder { + owner: ThreadId, + events: mpsc::Sender, +} + +impl Decoder for FailingDecoder { + fn decode(&mut self, _request: DecodeRequest) -> Result { + assert!( + self.events + .send(WorkerEvent::Decoded { + owner: self.owner, + current: std::thread::current().id(), + }) + .is_ok(), + "the test must receive decoder execution" + ); + Err(TranscribeError::inference(std::io::Error::other( + "fake decode failure", + ))) + } +} + +#[tokio::test] +async fn decode_failure_reaches_the_caller_on_the_decoder_owner_thread() { + let caller = std::thread::current().id(); + let (event_tx, event_rx) = mpsc::channel(); + let engine = SttEngine::new(FailingDecoderFactory { events: event_tx }, policy()) + .expect("fake decoder loads"); + let WorkerEvent::Created(created) = event_rx + .recv_timeout(Duration::from_secs(1)) + .expect("the factory records decoder creation") + else { + panic!("decoder creation must be the first event"); + }; + let error = engine + .decode(DecodeRequest::new( + DecodeMode::Interim, + vec![0.25; EnginePolicy::SAMPLE_RATE], + Vec::new(), + String::new(), + )) + .await + .expect_err("fake decode must fail"); + assert_eq!(error.to_string(), "transcribe audio window"); + let WorkerEvent::Decoded { owner, current } = event_rx + .recv_timeout(Duration::from_secs(1)) + .expect("the decoder records execution") + else { + panic!("decoder execution must follow creation"); + }; + assert_ne!(created, caller, "decoder creation uses a worker thread"); + assert_eq!(owner, created, "the decoder retains its creating worker"); + assert_eq!( + current, created, + "decode execution stays on the decoder's owning worker" + ); +} diff --git a/crates/gateway-stt-engine/tests/feature_boundary.rs b/crates/gateway-stt-engine/tests/feature_boundary.rs new file mode 100644 index 00000000..508c7468 --- /dev/null +++ b/crates/gateway-stt-engine/tests/feature_boundary.rs @@ -0,0 +1,179 @@ +//! Compile boundary for the feature-gated fixture surface. + +#![expect( + clippy::expect_used, + reason = "the compile fixture fails with the subprocess invariant named" +)] + +#[cfg(feature = "test-fixtures")] +use std::path::Path; + +#[cfg(feature = "test-fixtures")] +const CHILD_FALLBACK_ROOT: &str = "PROMPTFORGE_RESOLVER_CHILD_FALLBACK_ROOT"; +#[cfg(feature = "test-fixtures")] +const CHILD_FALLBACK_NAME: &str = "PROMPTFORGE_RESOLVER_CHILD_FALLBACK_NAME"; +#[cfg(feature = "test-fixtures")] +const CHILD_EXPECTED: &str = "PROMPTFORGE_RESOLVER_CHILD_EXPECTED"; +#[cfg(feature = "test-fixtures")] +const RESOLVER_OVERRIDE: &str = "PROMPTFORGE_RESOLVER_TEST_OVERRIDE"; + +#[cfg(not(feature = "test-fixtures"))] +#[test] +fn default_dependency_does_not_expose_test_fixtures() { + let temp = tempfile::tempdir().expect("temporary consumer directory"); + let source = temp.path().join("src"); + std::fs::create_dir(&source).expect("consumer source directory creates"); + let engine = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .display() + .to_string() + .replace('\\', "/"); + std::fs::write( + temp.path().join("Cargo.toml"), + format!( + "[package]\nname = \"fixture-boundary-consumer\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\n[dependencies]\ngateway-stt-engine = {{ path = {engine:?}, default-features = false }}\n" + ), + ) + .expect("consumer manifest writes"); + std::fs::write( + source.join("lib.rs"), + "pub use gateway_stt_engine::test_fixtures::native::require_fixture;\n", + ) + .expect("consumer source writes"); + + let output = std::process::Command::new(env!("CARGO")) + .arg("check") + .env("CARGO_NET_OFFLINE", "true") + .current_dir(temp.path()) + .output() + .expect("consumer cargo check runs"); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + !output.status.success(), + "default consumer unexpectedly compiled" + ); + assert!( + stderr.contains("could not find `test_fixtures` in `gateway_stt_engine`"), + "failure must prove fixture symbols are absent: {stderr}" + ); +} + +#[cfg(feature = "test-fixtures")] +#[test] +fn public_resolver_uses_the_callers_fallback_root() { + let temp = tempfile::tempdir().expect("temporary fixture directory"); + let fallback_root = temp.path().join("caller-owned"); + std::fs::create_dir(&fallback_root).expect("caller fixture directory creates"); + let fallback = fallback_root.join("model.bin"); + std::fs::write(&fallback, b"fallback").expect("fallback fixture writes"); + + let output = resolver_child(&fallback_root, "model.bin", &fallback, None); + + assert_child_succeeded(&output); +} + +#[cfg(feature = "test-fixtures")] +#[test] +fn public_resolver_prefers_the_process_environment_override() { + let temp = tempfile::tempdir().expect("temporary fixture directory"); + let fallback_root = temp.path().join("caller-owned"); + std::fs::create_dir(&fallback_root).expect("caller fixture directory creates"); + std::fs::write(fallback_root.join("model.bin"), b"fallback").expect("fallback fixture writes"); + let override_path = temp.path().join("override.bin"); + std::fs::write(&override_path, b"override").expect("override fixture writes"); + + let output = resolver_child( + &fallback_root, + "model.bin", + &override_path, + Some(&override_path), + ); + + assert_child_succeeded(&output); +} + +#[cfg(feature = "test-fixtures")] +#[test] +fn public_resolver_missing_file_diagnostic_names_the_resolved_path() { + let temp = tempfile::tempdir().expect("temporary fixture directory"); + let fallback_root = temp.path().join("caller-owned"); + let missing = fallback_root.join("whisper.dll"); + + let output = resolver_child(&fallback_root, "whisper.dll", &missing, None); + let diagnostic = format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + assert!( + !output.status.success(), + "missing fixture unexpectedly resolved" + ); + assert!( + diagnostic.contains("native test fixture is missing"), + "diagnostic classifies the failure: {diagnostic}" + ); + assert!( + diagnostic.contains(&missing.display().to_string()), + "diagnostic names the resolved path: {diagnostic}" + ); +} + +#[cfg(feature = "test-fixtures")] +fn resolver_child( + fallback_root: &Path, + fallback_name: &str, + expected: &Path, + override_path: Option<&Path>, +) -> std::process::Output { + let mut command = + std::process::Command::new(std::env::current_exe().expect("current test executable")); + command + .args([ + "--exact", + "public_resolver_child", + "--ignored", + "--nocapture", + ]) + .env(CHILD_FALLBACK_ROOT, fallback_root) + .env(CHILD_FALLBACK_NAME, fallback_name) + .env(CHILD_EXPECTED, expected) + .env_remove(RESOLVER_OVERRIDE); + if let Some(override_path) = override_path { + command.env(RESOLVER_OVERRIDE, override_path); + } + command.output().expect("resolver child runs") +} + +#[cfg(feature = "test-fixtures")] +fn assert_child_succeeded(output: &std::process::Output) { + assert!( + output.status.success(), + "resolver child failed:\n{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[cfg(feature = "test-fixtures")] +#[test] +#[ignore = "run only in an isolated environment by the public resolver contract tests"] +fn public_resolver_child() { + let Some(fallback_root) = std::env::var_os(CHILD_FALLBACK_ROOT) else { + return; + }; + let fallback_name = + std::env::var_os(CHILD_FALLBACK_NAME).expect("child fallback name is supplied"); + let expected = std::env::var_os(CHILD_EXPECTED).expect("child expected path is supplied"); + + let resolved = gateway_stt_engine::test_fixtures::native::require_fixture( + RESOLVER_OVERRIDE, + Path::new(&fallback_root), + Path::new(&fallback_name) + .to_str() + .expect("fixture name is valid UTF-8"), + ); + + assert_eq!(resolved, std::path::PathBuf::from(expected)); +} diff --git a/crates/gateway-stt-engine/tests/startup_cleanup.rs b/crates/gateway-stt-engine/tests/startup_cleanup.rs new file mode 100644 index 00000000..8bf89c91 --- /dev/null +++ b/crates/gateway-stt-engine/tests/startup_cleanup.rs @@ -0,0 +1,267 @@ +//! Partial-startup and role-ordered worker cleanup regressions. + +#![cfg(feature = "test-fixtures")] + +use gateway_stt_engine::test_fixtures::{ScriptedDecoder, ScriptedModelFactory}; +use gateway_stt_engine::{ + DecodeMode, Decoder, EnginePolicy, ModelFactory, SttEngine, TranscribeError, +}; +use std::path::PathBuf; +use std::sync::{Arc, Barrier}; +use std::time::Duration; +use thiserror as _; +use tokio as _; + +fn policy() -> EnginePolicy { + let Ok(policy) = EnginePolicy::new(15, 500, false) else { + panic!("test policy must be valid"); + }; + policy +} + +const INTERIM_SENTINEL: &str = "simultaneous interim startup failure"; +const FINAL_SENTINEL: &str = "simultaneous final startup failure"; + +#[derive(Debug)] +struct ConcurrentStartupFailureFactory { + rendezvous: Arc, + interim_is_missing: bool, +} + +impl ModelFactory for ConcurrentStartupFailureFactory { + fn create(&self, mode: DecodeMode) -> Result>, TranscribeError> { + self.rendezvous.wait(); + match (mode, self.interim_is_missing) { + (DecodeMode::Interim, true) => Ok(None), + (DecodeMode::Interim, false) => Err(TranscribeError::load_model( + PathBuf::from(INTERIM_SENTINEL), + std::io::Error::other(INTERIM_SENTINEL), + )), + (DecodeMode::Final, _) => Err(TranscribeError::load_model( + PathBuf::from(FINAL_SENTINEL), + std::io::Error::other(FINAL_SENTINEL), + )), + } + } +} + +fn concurrent_startup_failure(interim_is_missing: bool) -> TranscribeError { + let Err(error) = SttEngine::new( + ConcurrentStartupFailureFactory { + rendezvous: Arc::new(Barrier::new(2)), + interim_is_missing, + }, + policy(), + ) else { + panic!("both role outcomes prevent construction"); + }; + error +} + +#[test] +fn simultaneous_role_failures_preserve_both_exact_outcomes() { + let TranscribeError::StartupFailures { failures, .. } = concurrent_startup_failure(false) + else { + panic!("simultaneous role failures must be aggregated"); + }; + assert_eq!(failures.len(), 2); + assert_eq!( + failures[0].to_string(), + format!("load transcription model {INTERIM_SENTINEL}") + ); + assert_eq!( + failures[1].to_string(), + format!("load transcription model {FINAL_SENTINEL}") + ); +} + +#[test] +fn missing_interim_preserves_the_simultaneous_final_failure() { + let TranscribeError::StartupFailures { failures, .. } = concurrent_startup_failure(true) else { + panic!("the missing interim and final failure must be aggregated"); + }; + assert_eq!(failures.len(), 2); + assert_eq!( + failures[0].to_string(), + "invalid STT configuration: the interim decoder is required" + ); + assert_eq!( + failures[1].to_string(), + format!("load transcription model {FINAL_SENTINEL}") + ); +} + +#[test] +fn oversized_public_startup_timeout_returns_exact_invalid_configuration() { + let interim = ScriptedDecoder::new(); + let error = SttEngine::new( + ScriptedModelFactory::new(interim.clone()), + policy().with_startup_timeout(Duration::MAX), + ) + .expect_err("an unrepresentable absolute deadline must be rejected"); + assert_eq!( + error.to_string(), + "invalid STT configuration: stt.startup_timeout is too large" + ); + assert_eq!( + interim.creation_thread(), + None, + "deadline validation precedes worker construction" + ); +} + +#[test] +fn final_first_startup_failure_preserves_interim_cleanup_panic() { + const SENTINEL: &str = "scripted final startup with cleanup sentinel"; + + let interim = ScriptedDecoder::new(); + interim.panic_on_drop(); + let Err(error) = SttEngine::new( + ScriptedModelFactory::new(interim.clone()).with_final_failure(SENTINEL), + policy(), + ) else { + panic!("final startup and interim cleanup must both fail"); + }; + let TranscribeError::StartupCleanup { + startup, cleanup, .. + } = error + else { + panic!("startup and cleanup failures must both be typed"); + }; + assert_eq!( + startup.to_string(), + format!("invalid STT configuration: {SENTINEL}") + ); + assert_eq!(cleanup.len(), 1); + assert_eq!( + cleanup[0].to_string(), + "transcription worker panicked during shutdown" + ); + assert!(interim.worker_dropped()); +} + +#[test] +fn both_workers_start_concurrently_under_one_absolute_deadline() { + let interim = ScriptedDecoder::new(); + let final_decoder = ScriptedDecoder::new(); + let factory = ScriptedModelFactory::new(interim.clone()).with_final(final_decoder.clone()); + let policy = policy().with_startup_timeout(Duration::from_millis(200)); + let (result, ()) = factory + .with_construction_blocked( + Duration::from_secs(1), + Duration::from_millis(350), + |factory| SttEngine::new(factory, policy), + || { + assert_eq!( + interim.creation_thread(), + None, + "interim is observed parked before construction can complete" + ); + assert_eq!( + final_decoder.creation_thread(), + None, + "final is observed parked before construction can complete" + ); + }, + ) + .expect("both roles park before the bounded constructor result arrives"); + let error = result.expect_err("both parked workers share one startup deadline"); + let TranscribeError::StartupFailures { failures, .. } = error else { + panic!("both role-specific timeouts must be preserved"); + }; + assert_eq!(failures.len(), 2); + assert_eq!( + failures[0].to_string(), + "interim transcription worker startup timed out" + ); + assert_eq!( + failures[1].to_string(), + "final transcription worker startup timed out" + ); +} + +#[test] +fn shutdown_surfaces_interim_first_panic_and_still_joins_final() { + let interim = ScriptedDecoder::new(); + interim.panic_on_drop(); + let final_decoder = ScriptedDecoder::new(); + let Ok(engine) = SttEngine::new( + ScriptedModelFactory::new(interim.clone()).with_final(final_decoder.clone()), + policy(), + ) else { + panic!("scripted workers must start"); + }; + + let Err(error) = engine.shutdown() else { + panic!("interim shutdown must fail"); + }; + assert_eq!( + error.to_string(), + "transcription worker panicked during shutdown" + ); + assert!(interim.worker_dropped()); + assert!(final_decoder.worker_dropped()); +} + +#[test] +fn shutdown_surfaces_final_panic_after_interim_first_cleanup() { + let interim = ScriptedDecoder::new(); + let final_decoder = ScriptedDecoder::new(); + final_decoder.panic_on_drop(); + let Ok(engine) = SttEngine::new( + ScriptedModelFactory::new(interim.clone()).with_final(final_decoder.clone()), + policy(), + ) else { + panic!("scripted workers must start"); + }; + + let Err(error) = engine.shutdown() else { + panic!("final shutdown must fail"); + }; + assert_eq!( + error.to_string(), + "transcription worker panicked during shutdown" + ); + assert!(interim.worker_dropped()); + assert!(final_decoder.worker_dropped()); + + let Err(repeated) = engine.shutdown() else { + panic!("the final shutdown panic must remain visible"); + }; + assert_eq!( + repeated.to_string(), + "transcription worker panicked during shutdown" + ); +} + +#[test] +fn shutdown_aggregates_both_panics_and_repeats_the_complete_failure_set() { + let interim = ScriptedDecoder::new(); + interim.panic_on_drop(); + let final_decoder = ScriptedDecoder::new(); + final_decoder.panic_on_drop(); + let Ok(engine) = SttEngine::new( + ScriptedModelFactory::new(interim.clone()).with_final(final_decoder.clone()), + policy(), + ) else { + panic!("scripted workers must start"); + }; + + for call in 1..=2 { + let Err(TranscribeError::ShutdownFailures { cleanup, .. }) = engine.shutdown() else { + panic!("shutdown call {call} must report both worker panics"); + }; + assert_eq!( + cleanup.len(), + 2, + "shutdown call {call} preserves both failures" + ); + assert!( + cleanup + .iter() + .all(|error| error.to_string() == "transcription worker panicked during shutdown") + ); + } + assert!(interim.worker_dropped()); + assert!(final_decoder.worker_dropped()); +} diff --git a/crates/gateway-stt/AGENTS.md b/crates/gateway-stt/AGENTS.md index fc971f8b..71e100b6 100644 --- a/crates/gateway-stt/AGENTS.md +++ b/crates/gateway-stt/AGENTS.md @@ -1,8 +1,7 @@ # gateway-stt -This crate owns gateway-hosted speech-to-text runtime behavior: artifact provisioning, active-profile engine lifecycle, the `/stt` WebSocket, and the OpenAI-compatible transcription endpoint. +This crate is the gateway speech facade: artifact provisioning, engine lifecycle, batch transcription, and Realtime behavior. -- Runtime ownership only. Whisper inference primitives stay in `gateway-transcribe`; artifact download and verification stay in `gateway-local::artifacts::ArtifactStore`. -- The gateway selects profiles and supplies validated config. This crate provisions only the selected `Config::stt_models()` pair. -- The whisper.cpp runtime is provisioned through `ArtifactStore` and handed to `gateway-transcribe` as a path. Native backends are never Cargo features. -- `/stt` keeps its existing wire path and frame contract. OpenAI multipart input is capped at 25 MiB before decode. +- `take::Take` solely owns per-take guidance, finalized history, segmentation, hypothesis agreement, transcript aggregation, completion, and failure. +- Artifact download and verification stay in `gateway-local::artifacts::ArtifactStore`. +- Speech routes are OpenAI multipart batch transcription and Realtime transcription only. Multipart input is capped at 25 MiB before decode. diff --git a/crates/gateway-stt/Cargo.toml b/crates/gateway-stt/Cargo.toml index 89737f17..d4b425e3 100644 --- a/crates/gateway-stt/Cargo.toml +++ b/crates/gateway-stt/Cargo.toml @@ -11,29 +11,33 @@ description = "PromptForge gateway-owned speech-to-text runtime and HTTP endpoin [dependencies] axum.workspace = true +base64.workspace = true futures-util.workspace = true hound.workspace = true gateway-config.workspace = true gateway-local.workspace = true shared-progress.workspace = true -gateway-transcribe.workspace = true -workshop-server.workspace = true +gateway-stt-backend-whisper.workspace = true +gateway-stt-engine.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true tokio.workspace = true tracing.workspace = true -[features] -default = [] -test-fixtures = ["gateway-transcribe/test-fixtures"] - [dev-dependencies] gateway-stt = { path = ".", features = ["test-fixtures"] } sha2.workspace = true tempfile.workspace = true tokio-tungstenite.workspace = true +toml.workspace = true tower.workspace = true +[features] +test-fixtures = [ + "gateway-stt-backend-whisper/test-fixtures", + "gateway-stt-engine/test-fixtures", +] + [lints] workspace = true diff --git a/crates/gateway-stt/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml new file mode 100644 index 00000000..c0be4c38 --- /dev/null +++ b/crates/gateway-stt/module-ceilings.toml @@ -0,0 +1,51 @@ +# Exact source and public-root counts for the gateway STT facade. +# Physical lines include comments and blanks. Every recorded ceiling equals +# the measured file size, so any size change updates this manifest explicitly. + +public_root_count = 6 +test_fixture_public_root_count = 7 + +[modules] +"artifacts.rs" = 346 +"audio.rs" = 400 +"batch.rs" = 347 +"batch/native_tests.rs" = 113 +"batch/tests.rs" = 160 +"generation.rs" = 452 +"generation/lease.rs" = 130 +"generation/snapshot.rs" = 158 +"lib.rs" = 37 +"model.rs" = 105 +"realtime/mod.rs" = 17 +"realtime/input.rs" = 201 +"realtime/item.rs" = 181 +"realtime/query.rs" = 70 +"realtime/registry.rs" = 238 +"realtime/result_mailbox.rs" = 241 +"realtime/route.rs" = 438 +"realtime/session.rs" = 486 +"realtime/session/items.rs" = 162 +"realtime/session/route.rs" = 205 +"realtime/session/state.rs" = 113 +"realtime/wire.rs" = 24 +"realtime/wire/client.rs" = 363 +"realtime/wire/server.rs" = 400 +"realtime/wire/server/events.rs" = 180 +"realtime/wire/shared.rs" = 258 +"realtime/wire/tests.rs" = 278 +"replacement.rs" = 479 +"segment.rs" = 254 +"service.rs" = 126 +"status.rs" = 54 +"take.rs" = 274 +"take/agreement.rs" = 30 +"take/final_outcome.rs" = 98 +"take/finalization.rs" = 379 +"take/interim.rs" = 27 +"take/state.rs" = 157 +"take/text.rs" = 9 +"take/window.rs" = 250 +"test_fixtures.rs" = 442 +"test_fixtures/generation.rs" = 100 +"test_fixtures/native.rs" = 47 +"test_fixtures/segment.rs" = 14 diff --git a/crates/gateway-stt/public-api-default.txt b/crates/gateway-stt/public-api-default.txt new file mode 100644 index 00000000..715a9ae3 --- /dev/null +++ b/crates/gateway-stt/public-api-default.txt @@ -0,0 +1,63 @@ +pub mod gateway_stt +#[non_exhaustive] pub enum gateway_stt::SpeechError +#[non_exhaustive] pub gateway_stt::SpeechError::Artifact +pub gateway_stt::SpeechError::Artifact::model: alloc::string::String +pub gateway_stt::SpeechError::Artifact::source: gateway_local::error::LocalError +#[non_exhaustive] pub gateway_stt::SpeechError::Engine(gateway_stt_engine::error::TranscribeError) +pub gateway_stt::SpeechError::FileTooLarge +pub gateway_stt::SpeechError::GenerationActive +#[non_exhaustive] pub gateway_stt::SpeechError::Inference(gateway_stt_engine::error::TranscribeError) +#[non_exhaustive] pub gateway_stt::SpeechError::InvalidAudio(hound::Error) +#[non_exhaustive] pub gateway_stt::SpeechError::InvalidField +pub gateway_stt::SpeechError::InvalidField::field: &'static str +pub gateway_stt::SpeechError::InvalidField::value: alloc::string::String +#[non_exhaustive] pub gateway_stt::SpeechError::MissingField(&'static str) +pub gateway_stt::SpeechError::MissingInterim +#[non_exhaustive] pub gateway_stt::SpeechError::ModelNotFound(alloc::string::String) +#[non_exhaustive] pub gateway_stt::SpeechError::Multipart(axum::extract::multipart::MultipartError) +pub gateway_stt::SpeechError::QuiescenceDeadline +pub gateway_stt::SpeechError::ReplacementInvalidated +pub gateway_stt::SpeechError::ReplacementOwner +#[non_exhaustive] pub gateway_stt::SpeechError::ReservedModelName +pub gateway_stt::SpeechError::ReservedModelName::model: alloc::string::String +pub gateway_stt::SpeechError::Rollback +pub gateway_stt::SpeechError::Rollback::failure: alloc::boxed::Box +pub gateway_stt::SpeechError::Rollback::rollback: alloc::boxed::Box +#[non_exhaustive] pub gateway_stt::SpeechError::Store(gateway_local::error::LocalError) +#[non_exhaustive] pub gateway_stt::SpeechError::UnsupportedAudio +pub gateway_stt::SpeechError::UnsupportedAudio::channels: u16 +pub gateway_stt::SpeechError::UnsupportedAudio::sample_rate: u32 +#[non_exhaustive] pub gateway_stt::SpeechError::UnsupportedResponseFormat(alloc::string::String) +#[non_exhaustive] pub gateway_stt::SpeechError::UnsupportedRole +pub gateway_stt::SpeechError::UnsupportedRole::model: alloc::string::String +#[non_exhaustive] pub gateway_stt::SpeechError::WhisperLibrary(gateway_local::error::LocalError) +impl gateway_stt::SpeechError +pub fn gateway_stt::SpeechError::is_file_too_large(&self) -> bool +pub fn gateway_stt::SpeechError::is_inference(&self) -> bool +pub fn gateway_stt::SpeechError::is_non_preemptible_startup_timeout(&self) -> bool +pub fn gateway_stt::SpeechError::model_not_found(&self) -> core::option::Option<&str> +pub struct gateway_stt::PreparedSpeech +pub struct gateway_stt::SpeechModelInfo +impl gateway_stt::SpeechModelInfo +pub fn gateway_stt::SpeechModelInfo::name(&self) -> &str +pub struct gateway_stt::SpeechReplacement +impl core::ops::drop::Drop for gateway_stt::SpeechReplacement +pub fn gateway_stt::SpeechReplacement::drop(&mut self) +pub struct gateway_stt::SpeechService +impl gateway_stt::SpeechService +pub fn gateway_stt::SpeechService::abort_replacement(&self, gateway_stt::SpeechReplacement) -> core::result::Result<(), gateway_stt::SpeechError> +pub fn gateway_stt::SpeechService::begin_replacement(&self, gateway_stt::PreparedSpeech) -> core::result::Result +pub fn gateway_stt::SpeechService::begin_replacement_before(&self, gateway_stt::PreparedSpeech, std::time::Instant) -> core::result::Result +pub fn gateway_stt::SpeechService::commit_replacement(&self, gateway_stt::SpeechReplacement) -> core::result::Result<(), gateway_stt::SpeechError> +pub fn gateway_stt::SpeechService::models(&self) -> alloc::vec::Vec +pub fn gateway_stt::SpeechService::new() -> Self +pub fn gateway_stt::SpeechService::prepare(&self, &gateway_config::config::Config, core::option::Option<&shared_progress::handle::ProgressHandle>) -> core::result::Result +pub fn gateway_stt::SpeechService::routes(&self) -> axum::routing::Router +pub fn gateway_stt::SpeechService::shutdown(&self) +pub fn gateway_stt::SpeechService::status(&self) -> gateway_stt::SpeechStatus +pub struct gateway_stt::SpeechStatus +impl gateway_stt::SpeechStatus +pub const fn gateway_stt::SpeechStatus::configured(self) -> bool +pub const fn gateway_stt::SpeechStatus::generation(self) -> core::option::Option +pub const fn gateway_stt::SpeechStatus::gpu(self) -> bool +pub const fn gateway_stt::SpeechStatus::ready(self) -> bool diff --git a/crates/gateway-stt/public-api-test-fixtures.txt b/crates/gateway-stt/public-api-test-fixtures.txt new file mode 100644 index 00000000..087898f2 --- /dev/null +++ b/crates/gateway-stt/public-api-test-fixtures.txt @@ -0,0 +1,127 @@ +pub mod gateway_stt +pub mod gateway_stt::test_fixtures +pub use gateway_stt::test_fixtures::DecodeMode +pub use gateway_stt::test_fixtures::ScriptedDecoder +pub use gateway_stt::test_fixtures::ScriptedModelFactory +pub struct gateway_stt::test_fixtures::GenerationOwnershipFixture +impl gateway_stt::test_fixtures::GenerationOwnershipFixture +pub fn gateway_stt::test_fixtures::GenerationOwnershipFixture::epoch(&self) -> u64 +pub fn gateway_stt::test_fixtures::GenerationOwnershipFixture::is_replaced(&self) -> bool +pub fn gateway_stt::test_fixtures::GenerationOwnershipFixture::own_worker_job(&self) -> core::option::Option +pub struct gateway_stt::test_fixtures::GenerationWorkerJobFixture +impl core::ops::drop::Drop for gateway_stt::test_fixtures::GenerationWorkerJobFixture +pub fn gateway_stt::test_fixtures::GenerationWorkerJobFixture::drop(&mut self) +pub struct gateway_stt::test_fixtures::RealtimeCommitFixture(_) +impl gateway_stt::test_fixtures::RealtimeCommitFixture +pub fn gateway_stt::test_fixtures::RealtimeCommitFixture::item_id(&self) -> &str +pub fn gateway_stt::test_fixtures::RealtimeCommitFixture::previous_item_id(&self) -> core::option::Option<&str> +pub struct gateway_stt::test_fixtures::RealtimeInputSnapshotFixture +impl gateway_stt::test_fixtures::RealtimeInputSnapshotFixture +pub const fn gateway_stt::test_fixtures::RealtimeInputSnapshotFixture::include_hypothesis(&self) -> bool +pub fn gateway_stt::test_fixtures::RealtimeInputSnapshotFixture::item_id(&self) -> &str +pub fn gateway_stt::test_fixtures::RealtimeInputSnapshotFixture::prompt(&self) -> &str +pub struct gateway_stt::test_fixtures::RealtimeSessionFixture +impl gateway_stt::test_fixtures::RealtimeSessionFixture +pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::accept_interim_across_clear(&mut self, &str, &str) -> core::result::Result<(core::option::Option, core::option::Option), alloc::string::String> +pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::allocated_event_count(&self) -> u64 +pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::append_base64(&mut self, &str) -> core::result::Result<(), alloc::string::String> +pub const fn gateway_stt::test_fixtures::RealtimeSessionFixture::canceled_join_count(&self) -> usize +pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::clear(&mut self) -> core::result::Result<(), alloc::string::String> +pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::commit(&mut self) -> core::result::Result +pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::committed_count(&self) -> usize +pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::committed_prompt_and_guidance(&self, &str) -> core::option::Option<(alloc::string::String, alloc::vec::Vec)> +pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::drain_results(&mut self) -> alloc::vec::Vec +pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::fail_precommit(&mut self, &str) -> core::result::Result<(), alloc::string::String> +pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::finalize_completed(&mut self, &str, &str) -> core::result::Result<(), alloc::string::String> +pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::finalize_failed(&mut self, &str, &str) -> core::result::Result<(), alloc::string::String> +pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::finalizing_count(&self) -> usize +pub async fn gateway_stt::test_fixtures::RealtimeSessionFixture::finish_finalization(&mut self, &str) -> core::result::Result<(), alloc::string::String> +pub async fn gateway_stt::test_fixtures::RealtimeSessionFixture::finish_interim(&mut self) -> core::result::Result, alloc::string::String> +pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::input_snapshot(&self) -> core::option::Option +pub async fn gateway_stt::test_fixtures::RealtimeSessionFixture::join_canceled(&mut self) -> core::result::Result<(), alloc::string::String> +pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::pending_failure(&self) -> core::option::Option +pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::pending_final_segments(&self) -> core::option::Option +pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::push_delta(&mut self, &str, &str) -> core::result::Result<(), alloc::string::String> +pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::replace_finalization(&mut self, &str, F) -> core::result::Result<(), alloc::string::String> where F: core::future::future::Future> + core::marker::Send + 'static +pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::replace_hypothesis(&mut self, &str, u64, &str) -> core::result::Result<(), alloc::string::String> +pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::resampled_audio(&self) -> core::option::Option> +pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::spawn_interim(&mut self, F) -> core::result::Result<(), alloc::string::String> where F: core::future::future::Future + core::marker::Send + 'static +pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::update_text(&mut self, &str) -> core::result::Result<(), alloc::string::String> +pub struct gateway_stt::test_fixtures::RealtimeSessionRegistryFixture +impl gateway_stt::test_fixtures::RealtimeSessionRegistryFixture +pub fn gateway_stt::test_fixtures::RealtimeSessionRegistryFixture::active(&self) -> usize +pub fn gateway_stt::test_fixtures::RealtimeSessionRegistryFixture::cleanup_event_count(&self) -> usize +pub fn gateway_stt::test_fixtures::RealtimeSessionRegistryFixture::cleanup_notified(&self) -> impl core::future::future::Future + '_ +pub fn gateway_stt::test_fixtures::RealtimeSessionRegistryFixture::register(&self) -> core::result::Result +pub fn gateway_stt::test_fixtures::RealtimeSessionRegistryFixture::register_with_scripted_engine(&self, gateway_stt_engine::test_fixtures::ScriptedModelFactory) -> core::result::Result +pub fn gateway_stt::test_fixtures::RealtimeSessionRegistryFixture::retired_task_failures(&self) -> usize +pub fn gateway_stt::test_fixtures::begin_scripted_replacement(&gateway_stt::SpeechService, gateway_stt_engine::test_fixtures::ScriptedModelFactory, bool, core::time::Duration) -> core::result::Result +pub fn gateway_stt::test_fixtures::generation_counts(&gateway_stt::SpeechService) -> core::option::Option<(usize, usize)> +pub fn gateway_stt::test_fixtures::generation_ownership(&gateway_stt::SpeechService) -> core::option::Option +pub fn gateway_stt::test_fixtures::scripted_service(gateway_stt_engine::test_fixtures::ScriptedModelFactory, u64, u64) -> core::result::Result +pub fn gateway_stt::test_fixtures::segment_ranges(&[f32]) -> alloc::vec::Vec> +#[non_exhaustive] pub enum gateway_stt::SpeechError +#[non_exhaustive] pub gateway_stt::SpeechError::Artifact +pub gateway_stt::SpeechError::Artifact::model: alloc::string::String +pub gateway_stt::SpeechError::Artifact::source: gateway_local::error::LocalError +#[non_exhaustive] pub gateway_stt::SpeechError::Engine(gateway_stt_engine::error::TranscribeError) +pub gateway_stt::SpeechError::FileTooLarge +pub gateway_stt::SpeechError::GenerationActive +#[non_exhaustive] pub gateway_stt::SpeechError::Inference(gateway_stt_engine::error::TranscribeError) +#[non_exhaustive] pub gateway_stt::SpeechError::InvalidAudio(hound::Error) +#[non_exhaustive] pub gateway_stt::SpeechError::InvalidField +pub gateway_stt::SpeechError::InvalidField::field: &'static str +pub gateway_stt::SpeechError::InvalidField::value: alloc::string::String +#[non_exhaustive] pub gateway_stt::SpeechError::MissingField(&'static str) +pub gateway_stt::SpeechError::MissingInterim +#[non_exhaustive] pub gateway_stt::SpeechError::ModelNotFound(alloc::string::String) +#[non_exhaustive] pub gateway_stt::SpeechError::Multipart(axum::extract::multipart::MultipartError) +pub gateway_stt::SpeechError::QuiescenceDeadline +pub gateway_stt::SpeechError::ReplacementInvalidated +pub gateway_stt::SpeechError::ReplacementOwner +#[non_exhaustive] pub gateway_stt::SpeechError::ReservedModelName +pub gateway_stt::SpeechError::ReservedModelName::model: alloc::string::String +pub gateway_stt::SpeechError::Rollback +pub gateway_stt::SpeechError::Rollback::failure: alloc::boxed::Box +pub gateway_stt::SpeechError::Rollback::rollback: alloc::boxed::Box +#[non_exhaustive] pub gateway_stt::SpeechError::Store(gateway_local::error::LocalError) +#[non_exhaustive] pub gateway_stt::SpeechError::UnsupportedAudio +pub gateway_stt::SpeechError::UnsupportedAudio::channels: u16 +pub gateway_stt::SpeechError::UnsupportedAudio::sample_rate: u32 +#[non_exhaustive] pub gateway_stt::SpeechError::UnsupportedResponseFormat(alloc::string::String) +#[non_exhaustive] pub gateway_stt::SpeechError::UnsupportedRole +pub gateway_stt::SpeechError::UnsupportedRole::model: alloc::string::String +#[non_exhaustive] pub gateway_stt::SpeechError::WhisperLibrary(gateway_local::error::LocalError) +impl gateway_stt::SpeechError +pub fn gateway_stt::SpeechError::is_file_too_large(&self) -> bool +pub fn gateway_stt::SpeechError::is_inference(&self) -> bool +pub fn gateway_stt::SpeechError::is_non_preemptible_startup_timeout(&self) -> bool +pub fn gateway_stt::SpeechError::model_not_found(&self) -> core::option::Option<&str> +pub struct gateway_stt::PreparedSpeech +pub struct gateway_stt::SpeechModelInfo +impl gateway_stt::SpeechModelInfo +pub fn gateway_stt::SpeechModelInfo::name(&self) -> &str +pub struct gateway_stt::SpeechReplacement +impl core::ops::drop::Drop for gateway_stt::SpeechReplacement +pub fn gateway_stt::SpeechReplacement::drop(&mut self) +pub struct gateway_stt::SpeechService +impl gateway_stt::SpeechService +pub fn gateway_stt::SpeechService::abort_replacement(&self, gateway_stt::SpeechReplacement) -> core::result::Result<(), gateway_stt::SpeechError> +pub fn gateway_stt::SpeechService::begin_replacement(&self, gateway_stt::PreparedSpeech) -> core::result::Result +pub fn gateway_stt::SpeechService::begin_replacement_before(&self, gateway_stt::PreparedSpeech, std::time::Instant) -> core::result::Result +pub fn gateway_stt::SpeechService::block_realtime_send_after(&mut self, usize) +pub fn gateway_stt::SpeechService::commit_replacement(&self, gateway_stt::SpeechReplacement) -> core::result::Result<(), gateway_stt::SpeechError> +pub fn gateway_stt::SpeechService::fail_realtime_precommit(&mut self) +pub fn gateway_stt::SpeechService::models(&self) -> alloc::vec::Vec +pub fn gateway_stt::SpeechService::new() -> Self +pub fn gateway_stt::SpeechService::overload_realtime_final_segment(&mut self) +pub fn gateway_stt::SpeechService::prepare(&self, &gateway_config::config::Config, core::option::Option<&shared_progress::handle::ProgressHandle>) -> core::result::Result +pub fn gateway_stt::SpeechService::routes(&self) -> axum::routing::Router +pub fn gateway_stt::SpeechService::shutdown(&self) +pub fn gateway_stt::SpeechService::status(&self) -> gateway_stt::SpeechStatus +pub struct gateway_stt::SpeechStatus +impl gateway_stt::SpeechStatus +pub const fn gateway_stt::SpeechStatus::configured(self) -> bool +pub const fn gateway_stt::SpeechStatus::generation(self) -> core::option::Option +pub const fn gateway_stt::SpeechStatus::gpu(self) -> bool +pub const fn gateway_stt::SpeechStatus::ready(self) -> bool diff --git a/crates/gateway-stt/src/api.rs b/crates/gateway-stt/src/api.rs deleted file mode 100644 index e5684de5..00000000 --- a/crates/gateway-stt/src/api.rs +++ /dev/null @@ -1,625 +0,0 @@ -//! OpenAI-compatible multipart transcription handling. - -use std::io::Cursor; - -use axum::extract::Multipart; -use axum::response::{IntoResponse, Response}; -use gateway_transcribe::SAMPLE_RATE; -use serde::Serialize; - -use crate::runtime::{LoadedModelRole, SttState}; - -/// Maximum accepted audio file size: 25 MiB. -pub const MAX_AUDIO_BYTES: usize = 25 * 1024 * 1024; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ResponseFormat { - Json, - VerboseJson, -} - -#[derive(Debug)] -struct TranscriptionForm { - file: Vec, - model: String, - language: Option, - format: ResponseFormat, - granularities: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum TimestampGranularity { - Word, - Segment, -} - -fn default_granularities() -> Vec { - vec![TimestampGranularity::Segment] -} - -/// A basic OpenAI transcription response. -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -struct JsonTranscription { - /// The decoded transcript. - pub text: String, -} - -/// One clip-level segment in a verbose transcription response. -#[derive(Debug, Clone, PartialEq, Serialize)] -struct TranscriptionSegment { - /// Zero-based segment identifier. - pub id: u32, - /// Segment start in seconds. - pub start: f64, - /// Segment end in seconds. - pub end: f64, - /// Text decoded for the segment. - pub text: String, -} - -/// An OpenAI verbose transcription response. -#[derive(Debug, Clone, PartialEq, Serialize)] -struct VerboseJsonTranscription { - /// Requested task name. - pub task: &'static str, - /// Detected or caller-supplied language. - pub language: String, - /// Audio duration in seconds. - pub duration: f64, - /// The decoded transcript. - pub text: String, - /// Clip-level segments when segment granularity was requested. - pub segments: Vec, - /// Word timestamps. The current engine exposes no word alignment, so this - /// array stays empty when word granularity is requested. - pub words: Vec, -} - -/// A successful transcription in the requested OpenAI JSON dialect. -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(untagged)] -enum TranscriptionResponse { - /// The compact `json` response. - Json(JsonTranscription), - /// The `verbose_json` response. - VerboseJson(VerboseJsonTranscription), -} - -/// Parses and executes one OpenAI-compatible multipart transcription. -/// -/// The multipart dialect accepts `file`, `model`, `language`, `prompt`, -/// `temperature`, `response_format`, and the literal repeated field name -/// `timestamp_granularities[]`. -/// -/// # Errors -/// Returns [`TranscriptionError::FileTooLarge`] above 25 MiB, -/// [`TranscriptionError::ModelNotFound`] when `model` is not active, -/// [`TranscriptionError::InvalidAudio`] for audio other than 16 kHz mono -/// WAV, and the other variants for malformed multipart fields or inference -/// failure. -pub async fn transcribe( - state: &SttState, - multipart: Multipart, -) -> Result { - let form = parse_form(multipart).await?; - let Some((engine, role)) = state.select(&form.model) else { - return Err(TranscriptionError::ModelNotFound(form.model)); - }; - let (samples, duration) = decode_wav(&form.file)?; - let text = match role { - LoadedModelRole::Interim => engine.transcribe(samples).await, - LoadedModelRole::Final => engine - .transcribe_final(samples) - .await - .ok_or_else(|| TranscriptionError::ModelNotFound(form.model.clone()))?, - } - .map_err(TranscriptionError::Inference)?; - Ok(axum::Json(response(form, text, duration)).into_response()) -} - -async fn parse_form(mut multipart: Multipart) -> Result { - let mut file = None; - let mut model = None; - let mut language = None; - let mut format = ResponseFormat::Json; - let mut granularities = default_granularities(); - while let Some(mut field) = multipart - .next_field() - .await - .map_err(TranscriptionError::Multipart)? - { - let Some(name) = field.name().map(str::to_owned) else { - continue; - }; - match name.as_str() { - "file" => { - let mut bytes = Vec::new(); - while let Some(chunk) = - field.chunk().await.map_err(TranscriptionError::Multipart)? - { - if bytes.len().saturating_add(chunk.len()) > MAX_AUDIO_BYTES { - return Err(TranscriptionError::FileTooLarge); - } - bytes.extend_from_slice(&chunk); - } - file = Some(bytes); - } - "model" => model = Some(field_text(field).await?), - "language" => language = Some(field_text(field).await?), - "response_format" => { - format = match field_text(field).await?.as_str() { - "json" => ResponseFormat::Json, - "verbose_json" => ResponseFormat::VerboseJson, - value => { - return Err(TranscriptionError::UnsupportedResponseFormat( - value.to_owned(), - )); - } - }; - } - "timestamp_granularities[]" => { - granularities.push(match field_text(field).await?.as_str() { - "word" => TimestampGranularity::Word, - "segment" => TimestampGranularity::Segment, - value => { - return Err(TranscriptionError::InvalidField { - field: "timestamp_granularities[]", - value: value.to_owned(), - }); - } - }); - } - "temperature" => { - let value = field_text(field).await?; - let parsed = - value - .parse::() - .map_err(|_| TranscriptionError::InvalidField { - field: "temperature", - value: value.clone(), - })?; - if !parsed.is_finite() || parsed < 0.0 { - return Err(TranscriptionError::InvalidField { - field: "temperature", - value, - }); - } - } - // OpenAI-compatible hints accepted by the dialect. The current - // English whisper workers already own their prompt policy. - "prompt" => { - let _ignored = field_text(field).await?; - } - _ => {} - } - } - Ok(TranscriptionForm { - file: file.ok_or(TranscriptionError::MissingField("file"))?, - model: model.ok_or(TranscriptionError::MissingField("model"))?, - language, - format, - granularities, - }) -} - -async fn field_text( - field: axum::extract::multipart::Field<'_>, -) -> Result { - field.text().await.map_err(TranscriptionError::Multipart) -} - -#[expect( - clippy::cast_precision_loss, - reason = "PCM normalization and clip duration intentionally convert bounded audio counts to floating point" -)] -fn decode_wav(bytes: &[u8]) -> Result<(Vec, f64), TranscriptionError> { - const SAMPLE_RATE_U32: u32 = 16_000; - let mut reader = - hound::WavReader::new(Cursor::new(bytes)).map_err(TranscriptionError::InvalidAudio)?; - let spec = reader.spec(); - if spec.channels != 1 || spec.sample_rate != SAMPLE_RATE_U32 { - return Err(TranscriptionError::UnsupportedAudio { - sample_rate: spec.sample_rate, - channels: spec.channels, - }); - } - let samples = match spec.sample_format { - hound::SampleFormat::Float => reader - .samples::() - .collect::, _>>() - .map_err(TranscriptionError::InvalidAudio)?, - hound::SampleFormat::Int => { - let denominator = 2_f32.powi(i32::from(spec.bits_per_sample.saturating_sub(1))); - reader - .samples::() - .map(|sample| { - sample - .map(|value| value as f32 / denominator) - .map_err(TranscriptionError::InvalidAudio) - }) - .collect::, _>>()? - } - }; - let duration = samples.len() as f64 / SAMPLE_RATE as f64; - Ok((samples, duration)) -} - -fn response(form: TranscriptionForm, text: String, duration: f64) -> TranscriptionResponse { - match form.format { - ResponseFormat::Json => TranscriptionResponse::Json(JsonTranscription { text }), - ResponseFormat::VerboseJson => { - let segments = if form.granularities.contains(&TimestampGranularity::Segment) { - vec![TranscriptionSegment { - id: 0, - start: 0.0, - end: duration, - text: text.clone(), - }] - } else { - Vec::new() - }; - TranscriptionResponse::VerboseJson(VerboseJsonTranscription { - task: "transcribe", - language: form.language.unwrap_or_else(|| "en".to_owned()), - duration, - text, - segments, - words: Vec::new(), - }) - } - } -} - -/// A multipart transcription request failure. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum TranscriptionError { - /// Multipart framing could not be decoded. - #[non_exhaustive] - #[error("invalid multipart transcription request")] - Multipart(#[source] axum::extract::multipart::MultipartError), - - /// A required form field was absent. - #[non_exhaustive] - #[error("missing multipart field {0}")] - MissingField(&'static str), - - /// One form field carried an unsupported value. - #[non_exhaustive] - #[error("invalid multipart field {field}: {value}")] - InvalidField { - /// Literal field name. - field: &'static str, - /// Refused field value. - value: String, - }, - - /// The requested response format is not implemented. - #[non_exhaustive] - #[error("unsupported transcription response format {0}")] - UnsupportedResponseFormat(String), - - /// The audio file exceeded 25 MiB. - #[error("audio file exceeds the 25 MiB limit")] - FileTooLarge, - - /// The requested model is not loaded in the active profile. - #[non_exhaustive] - #[error("unknown model {0}")] - ModelNotFound(String), - - /// WAV parsing failed. - #[non_exhaustive] - #[error("invalid WAV audio")] - InvalidAudio(#[source] hound::Error), - - /// The WAV sample rate or channel count is unsupported. - #[non_exhaustive] - #[error("audio must be 16 kHz mono, got {sample_rate} Hz and {channels} channels")] - UnsupportedAudio { - /// Input sample rate. - sample_rate: u32, - /// Input channel count. - channels: u16, - }, - - /// Whisper rejected the audio. - #[non_exhaustive] - #[error("transcribe audio")] - Inference(#[source] gateway_transcribe::TranscribeError), -} - -impl TranscriptionError { - /// Builds a loaded-model selection failure. - #[must_use] - pub fn model_not_found_error(model: impl Into) -> Self { - Self::ModelNotFound(model.into()) - } - - /// Returns the unknown model name for a model-selection failure. - #[must_use] - pub fn model_not_found(&self) -> Option<&str> { - match self { - Self::ModelNotFound(model) => Some(model), - _ => None, - } - } - - /// Returns whether the caller exceeded the upload cap. - #[must_use] - pub fn is_file_too_large(&self) -> bool { - matches!(self, Self::FileTooLarge) - } - - /// Returns whether whisper inference failed after request validation. - #[must_use] - pub fn is_inference(&self) -> bool { - matches!(self, Self::Inference(_)) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::body::Body; - use axum::extract::State; - use axum::http::{Request, StatusCode}; - use axum::routing::post; - use tower::ServiceExt; - - fn wav(samples: &[i16]) -> Vec { - let mut bytes = Cursor::new(Vec::new()); - { - let mut writer = hound::WavWriter::new( - &mut bytes, - hound::WavSpec { - channels: 1, - sample_rate: 16_000, - bits_per_sample: 16, - sample_format: hound::SampleFormat::Int, - }, - ) - .expect("writer builds"); - for sample in samples { - writer.write_sample(*sample).expect("sample writes"); - } - writer.finalize().expect("WAV finalizes"); - } - bytes.into_inner() - } - - fn wav_f32(samples: &[f32]) -> Vec { - let mut bytes = Cursor::new(Vec::new()); - { - let mut writer = hound::WavWriter::new( - &mut bytes, - hound::WavSpec { - channels: 1, - sample_rate: 16_000, - bits_per_sample: 32, - sample_format: hound::SampleFormat::Float, - }, - ) - .expect("writer builds"); - for sample in samples { - writer.write_sample(*sample).expect("sample writes"); - } - writer.finalize().expect("WAV finalizes"); - } - bytes.into_inner() - } - - #[test] - fn wav_decode_accepts_the_stt_wire_sample_rate() { - let (samples, duration) = decode_wav(&wav(&[0, i16::MAX])).expect("WAV decodes"); - assert_eq!(samples.len(), 2); - assert!(samples[1] > 0.99); - assert!((duration - 2.0 / 16_000.0).abs() < f64::EPSILON); - } - - #[test] - fn verbose_json_honors_segment_granularity() { - let response = response( - TranscriptionForm { - file: Vec::new(), - model: "speech".to_owned(), - language: Some("en".to_owned()), - format: ResponseFormat::VerboseJson, - granularities: vec![TimestampGranularity::Segment], - }, - "hello".to_owned(), - 1.25, - ); - let json = serde_json::to_value(response).expect("response serializes"); - assert_eq!(json["text"], "hello"); - assert_eq!(json["duration"], 1.25); - assert_eq!(json["segments"][0]["end"], 1.25); - } - - #[test] - fn verbose_json_defaults_to_segment_timestamps() { - let granularities = default_granularities(); - let response = response( - TranscriptionForm { - file: Vec::new(), - model: "speech".to_owned(), - language: None, - format: ResponseFormat::VerboseJson, - granularities, - }, - "hello".to_owned(), - 1.25, - ); - let json = serde_json::to_value(response).expect("response serializes"); - assert_eq!(json["segments"][0]["text"], "hello"); - } - - #[test] - fn compact_json_contains_only_text() { - let response = response( - TranscriptionForm { - file: Vec::new(), - model: "speech".to_owned(), - language: None, - format: ResponseFormat::Json, - granularities: Vec::new(), - }, - "hello".to_owned(), - 1.0, - ); - assert_eq!( - serde_json::to_value(response).expect("response serializes"), - serde_json::json!({"text": "hello"}) - ); - } - - fn multipart_body(file: &[u8], fields: &[(&str, &str)]) -> (String, Vec) { - const BOUNDARY: &str = "gateway-stt-boundary"; - let mut body = Vec::new(); - for (name, value) in fields { - body.extend_from_slice(format!("--{BOUNDARY}\r\n").as_bytes()); - body.extend_from_slice( - format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n") - .as_bytes(), - ); - } - body.extend_from_slice(format!("--{BOUNDARY}\r\n").as_bytes()); - body.extend_from_slice( - b"Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n\ - Content-Type: audio/wav\r\n\r\n", - ); - body.extend_from_slice(file); - body.extend_from_slice(format!("\r\n--{BOUNDARY}--\r\n").as_bytes()); - (BOUNDARY.to_owned(), body) - } - - async fn test_endpoint(State(state): State, multipart: Multipart) -> Response { - match transcribe(&state, multipart).await { - Ok(response) => response.into_response(), - Err(error) if error.model_not_found().is_some() => { - (StatusCode::NOT_FOUND, error.to_string()).into_response() - } - Err(error) => (StatusCode::BAD_REQUEST, error.to_string()).into_response(), - } - } - - #[tokio::test] - async fn an_unloaded_model_is_not_found() { - let (boundary, body) = multipart_body( - &wav(&vec![0; 16_000]), - &[("model", "not-loaded"), ("response_format", "json")], - ); - let response = axum::Router::new() - .route("/v1/audio/transcriptions", post(test_endpoint)) - .layer(axum::extract::DefaultBodyLimit::max( - MAX_AUDIO_BYTES + 1024 * 1024, - )) - .with_state(SttState::default()) - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/audio/transcriptions") - .header( - "content-type", - format!("multipart/form-data; boundary={boundary}"), - ) - .body(Body::from(body)) - .expect("request builds"), - ) - .await - .expect("route answers"); - assert_eq!(response.status(), StatusCode::NOT_FOUND); - } - - #[tokio::test] - async fn an_audio_file_over_25_mib_is_rejected_before_decode() { - let oversized = vec![0_u8; MAX_AUDIO_BYTES + 1]; - let (boundary, body) = multipart_body(&oversized, &[("model", "speech")]); - let response = axum::Router::new() - .route("/v1/audio/transcriptions", post(test_endpoint)) - .layer(axum::extract::DefaultBodyLimit::max( - MAX_AUDIO_BYTES + 1024 * 1024, - )) - .with_state(SttState::default()) - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/audio/transcriptions") - .header( - "content-type", - format!("multipart/form-data; boundary={boundary}"), - ) - .body(Body::from(body)) - .expect("request builds"), - ) - .await - .expect("route answers"); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body reads"); - assert_eq!(&body[..], b"audio file exceeds the 25 MiB limit"); - } - - #[tokio::test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - async fn verbose_round_trip_accepts_literal_timestamp_granularities_field() { - let dir = tempfile::tempdir().expect("tempdir"); - let source = gateway_transcribe::fixtures::require_model() - .display() - .to_string() - .replace('\\', "/"); - let cache = dir.path().display().to_string().replace('\\', "/"); - let catalog = gateway_config::Config::from_toml_str(&format!( - "config-version = 2\n\ - [server]\nbind = \"127.0.0.1:0\"\napi_key = \"k\"\n\ - [local]\ncache_dir = {cache:?}\n\ - [workshop]\n\ - [[stt_model]]\nname = \"speech\"\nrole = \"interim\"\nsource = {source:?}\n\ - vram_gb = 1.0\n\ - [[profile]]\nname = \"work\"\nmodels = [\"speech\"]\n" - )) - .expect("catalog parses"); - let config = catalog - .select_profile(&gateway_config::ProfileName::parse("work").expect("name")) - .expect("profile selects"); - let state = SttState::default(); - let runtime = crate::SttRuntime::start(&config, state.clone(), None).expect("engine loads"); - let samples = gateway_transcribe::fixtures::jfk_samples(); - let (boundary, body) = multipart_body( - &wav_f32(&samples), - &[ - ("model", "speech"), - ("response_format", "verbose_json"), - ("timestamp_granularities[]", "segment"), - ], - ); - let response = axum::Router::new() - .route("/v1/audio/transcriptions", post(test_endpoint)) - .with_state(state) - .oneshot( - Request::builder() - .method("POST") - .uri("/v1/audio/transcriptions") - .header( - "content-type", - format!("multipart/form-data; boundary={boundary}"), - ) - .body(Body::from(body)) - .expect("request builds"), - ) - .await - .expect("route answers"); - assert_eq!(response.status(), StatusCode::OK); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body reads"); - let json: serde_json::Value = serde_json::from_slice(&body).expect("body is JSON"); - assert!( - json["text"] - .as_str() - .is_some_and(|text| text.to_lowercase().contains("country")) - ); - assert_eq!(json["segments"][0]["start"], 0.0); - runtime.shutdown(); - } -} diff --git a/crates/gateway-stt/src/artifacts.rs b/crates/gateway-stt/src/artifacts.rs new file mode 100644 index 00000000..493bcb59 --- /dev/null +++ b/crates/gateway-stt/src/artifacts.rs @@ -0,0 +1,346 @@ +//! Verified speech artifacts and facade error vocabulary. + +use std::path::PathBuf; + +use gateway_config::{Config, SttRole}; +use gateway_local::artifacts::ArtifactStore; +use shared_progress::ProgressHandle; + +use crate::model::{ModelNames, REALTIME_TRANSCRIBE_MODEL}; + +/// Verified artifacts and policy for a generation that has not started workers. +#[derive(Debug)] +pub struct PreparedSpeech { + pub(crate) generation: Option, +} + +#[derive(Debug)] +pub(crate) struct PreparedGeneration { + pub(crate) library: PathBuf, + pub(crate) interim_model: PathBuf, + pub(crate) final_model: Option, + pub(crate) names: ModelNames, + pub(crate) guidance: Vec, + pub(crate) window_seconds: u64, + pub(crate) interval_ms: u64, + pub(crate) progress: Option, +} + +#[derive(Debug, Default)] +struct ProvisionedModels { + interim: Option<(String, PathBuf)>, + final_model: Option<(String, PathBuf)>, +} + +pub(crate) fn prepare( + config: &Config, + progress: Option<&ProgressHandle>, +) -> Result { + if config.stt_models().is_empty() { + return Ok(PreparedSpeech { generation: None }); + } + if let Some(model) = config + .stt_models() + .iter() + .find(|model| model.name() == REALTIME_TRANSCRIBE_MODEL) + { + return Err(SpeechError::ReservedModelName { + model: model.name().to_owned(), + }); + } + + let cache = gateway_local::resolve_cache_root(config.local().cache_dir()) + .map_err(SpeechError::Store)?; + let store = ArtifactStore::new(cache).map_err(SpeechError::Store)?; + let library_progress = progress.map(|handle| handle.child("whisper-library", 1.0)); + let library = store + .provision_whisper_library(library_progress.as_ref()) + .map_err(SpeechError::WhisperLibrary)?; + let models = provision_models(config, &store, progress)?; + let Some((interim_name, interim_model)) = models.interim else { + return Err(SpeechError::MissingInterim); + }; + let capture = config.stt().cloned().unwrap_or_default(); + let (final_name, final_model) = models + .final_model + .map_or((None, None), |(name, path)| (Some(name), Some(path))); + + Ok(PreparedSpeech { + generation: Some(PreparedGeneration { + library, + interim_model, + final_model, + names: ModelNames::new(interim_name, final_name).map_err(|error| { + SpeechError::ReservedModelName { + model: error.into_name(), + } + })?, + guidance: capture.vocabulary().to_vec(), + window_seconds: capture.window_seconds(), + interval_ms: capture.interval_ms(), + progress: progress.map(|handle| handle.child("engine", 1.0)), + }), + }) +} + +fn provision_models( + config: &Config, + store: &ArtifactStore, + progress: Option<&ProgressHandle>, +) -> Result { + let mut provisioned = ProvisionedModels::default(); + for model in config.stt_models() { + let model_progress = progress.map(|handle| handle.child(model.name(), 4.0)); + let path = store + .ensure_model_with_progress(model.source(), model.sha256(), model_progress.as_ref()) + .map_err(|source| SpeechError::Artifact { + model: model.name().to_owned(), + source, + })?; + match model.role() { + SttRole::Interim => provisioned.interim = Some((model.name().to_owned(), path)), + SttRole::Final => provisioned.final_model = Some((model.name().to_owned(), path)), + _ => { + return Err(SpeechError::UnsupportedRole { + model: model.name().to_owned(), + }); + } + } + } + Ok(provisioned) +} + +/// A speech preparation, lifecycle, or request failure. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum SpeechError { + /// The artifact store could not be opened. + #[non_exhaustive] + #[error("open STT artifact store")] + Store(#[source] gateway_local::LocalError), + + /// The platform whisper.cpp runtime could not be provisioned. + #[non_exhaustive] + #[error("provision whisper library")] + WhisperLibrary(#[source] gateway_local::LocalError), + + /// One model could not be provisioned. + #[non_exhaustive] + #[error("provision STT model {model}")] + Artifact { + /// Catalog name of the model that failed. + model: String, + /// Artifact download, confinement, or verification failure. + #[source] + source: gateway_local::LocalError, + }, + + /// A final model was selected without its required interim partner. + #[error("final STT model requires an interim model")] + MissingInterim, + + /// The logical Realtime identity was used by one physical worker. + #[non_exhaustive] + #[error("STT model name {model} is reserved for the logical Realtime model")] + ReservedModelName { + /// Physical catalog name that collided with the logical identity. + model: String, + }, + + /// A future role reached a service that does not implement it. + #[non_exhaustive] + #[error("STT model {model} has an unsupported role")] + UnsupportedRole { + /// Catalog name carrying the unsupported role. + model: String, + }, + + /// The provisioned backend or worker pair could not be loaded. + #[non_exhaustive] + #[error("load STT engine")] + Engine(#[source] gateway_stt_engine::TranscribeError), + + /// A replacement token belongs to another service. + #[error("speech replacement belongs to another service")] + ReplacementOwner, + + /// A replacement was committed while a generation was still active. + #[error("an active speech generation must be shut down before replacement")] + GenerationActive, + + /// Old-generation ownership did not drain before replacement's deadline. + #[error("speech generation quiescence deadline expired")] + QuiescenceDeadline, + + /// Shutdown invalidated a replacement before it could publish. + #[error("speech replacement was invalidated by shutdown")] + ReplacementInvalidated, + + /// Reconstructing the old generation failed after a determinate replacement failure. + #[error("speech replacement failed ({failure}); reconstruct old generation ({rollback})")] + Rollback { + /// The determinate failure that required reconstruction. + failure: Box, + /// The failure returned while reconstructing the old specification. + rollback: Box, + }, + + /// Multipart framing could not be decoded. + #[non_exhaustive] + #[error("invalid multipart transcription request")] + Multipart(#[source] axum::extract::multipart::MultipartError), + + /// A required form field was absent. + #[non_exhaustive] + #[error("missing multipart field {0}")] + MissingField(&'static str), + + /// One form field carried an unsupported value. + #[non_exhaustive] + #[error("invalid multipart field {field}: {value}")] + InvalidField { + /// Literal field name. + field: &'static str, + /// Refused field value. + value: String, + }, + + /// The requested response format is not implemented. + #[non_exhaustive] + #[error("unsupported transcription response format {0}")] + UnsupportedResponseFormat(String), + + /// The audio file exceeded 25 MiB. + #[error("audio file exceeds the 25 MiB limit")] + FileTooLarge, + + /// The requested model is not loaded in the active generation. + #[non_exhaustive] + #[error("unknown model {0}")] + ModelNotFound(String), + + /// WAV parsing failed. + #[non_exhaustive] + #[error("invalid WAV audio")] + InvalidAudio(#[source] hound::Error), + + /// The WAV sample rate or channel count is unsupported. + #[non_exhaustive] + #[error("audio must be 16 kHz mono, got {sample_rate} Hz and {channels} channels")] + UnsupportedAudio { + /// Input sample rate. + sample_rate: u32, + /// Input channel count. + channels: u16, + }, + + /// The active worker rejected otherwise valid audio. + #[non_exhaustive] + #[error("transcribe audio")] + Inference(#[source] gateway_stt_engine::TranscribeError), +} + +impl SpeechError { + /// Returns whether worker construction exceeded a deadline and left a + /// non-preemptible native call running. + #[must_use] + pub fn is_non_preemptible_startup_timeout(&self) -> bool { + match self { + Self::Engine(error) => error.is_non_preemptible_startup_timeout(), + Self::Rollback { failure, rollback } => { + failure.is_non_preemptible_startup_timeout() + || rollback.is_non_preemptible_startup_timeout() + } + _ => false, + } + } + + /// Returns the unknown physical model name for a selection failure. + #[must_use] + pub fn model_not_found(&self) -> Option<&str> { + match self { + Self::ModelNotFound(model) => Some(model), + _ => None, + } + } + + /// Returns whether the caller exceeded the upload cap. + #[must_use] + pub fn is_file_too_large(&self) -> bool { + matches!(self, Self::FileTooLarge) + } + + /// Returns whether decoding failed after request validation. + #[must_use] + pub fn is_inference(&self) -> bool { + matches!(self, Self::Inference(_)) + } +} + +#[cfg(test)] +mod tests { + use std::fmt::Write as _; + + use sha2::{Digest, Sha256}; + + use super::*; + + fn selected(source: &str, sha256: Option<&str>) -> Config { + let pin = sha256.map_or_else(String::new, |pin| format!("sha256 = \"{pin}\"\n")); + let catalog = Config::from_toml_str(&format!( + "config-version = 2\n\ + [server]\nbind = \"127.0.0.1:0\"\napi_key = \"k\"\n\ + [workshop]\n\ + [[stt_model]]\nname = \"speech\"\nrole = \"interim\"\nsource = {source:?}\n\ + {pin}vram_gb = 1.0\n\ + [[profile]]\nname = \"work\"\nmodels = [\"speech\"]\n" + )) + .expect("catalog parses"); + catalog + .select_profile(&gateway_config::ProfileName::parse("work").expect("name")) + .expect("profile selects") + } + + #[test] + fn a_pinned_model_rejects_the_wrong_digest() { + let dir = tempfile::tempdir().expect("tempdir"); + let model = dir.path().join("model.bin"); + std::fs::write(&model, b"model bytes").expect("fixture writes"); + let config = selected(&model.display().to_string(), Some(&"0".repeat(64))); + let store = ArtifactStore::new(dir.path().join("cache")).expect("store builds"); + let error = provision_models(&config, &store, None).expect_err("bad pin must fail"); + assert!(matches!(error, SpeechError::Artifact { .. })); + } + + #[test] + fn an_unpinned_local_model_provisions() { + let dir = tempfile::tempdir().expect("tempdir"); + let model = dir.path().join("model.bin"); + std::fs::write(&model, b"model bytes").expect("fixture writes"); + let config = selected(&model.display().to_string(), None); + let store = ArtifactStore::new(dir.path().join("cache")).expect("store builds"); + let provisioned = provision_models(&config, &store, None).expect("unpinned path works"); + assert_eq!( + provisioned.interim.as_ref().map(|(_, path)| path), + Some(&model) + ); + } + + #[test] + fn a_pinned_model_accepts_the_matching_digest() { + let dir = tempfile::tempdir().expect("tempdir"); + let model = dir.path().join("model.bin"); + std::fs::write(&model, b"model bytes").expect("fixture writes"); + let mut pin = String::with_capacity(64); + for byte in Sha256::digest(b"model bytes") { + write!(&mut pin, "{byte:02x}").expect("writing to String is infallible"); + } + let config = selected(&model.display().to_string(), Some(&pin)); + let store = ArtifactStore::new(dir.path().join("cache")).expect("store builds"); + let provisioned = provision_models(&config, &store, None).expect("matching pin works"); + assert_eq!( + provisioned.interim.as_ref().map(|(_, path)| path), + Some(&model) + ); + } +} diff --git a/crates/gateway-stt/src/audio.rs b/crates/gateway-stt/src/audio.rs new file mode 100644 index 00000000..99c3cc75 --- /dev/null +++ b/crates/gateway-stt/src/audio.rs @@ -0,0 +1,400 @@ +use base64::Engine as _; + +const INPUT_SAMPLE_RATE: usize = 24_000; +const OUTPUT_SAMPLE_RATE: usize = 16_000; +const BYTES_PER_SAMPLE: usize = size_of::(); +const MAX_BUFFERED_SECONDS: usize = 30; +const MIN_COMMIT_MILLISECONDS: usize = 100; + +pub(super) const MAX_APPEND_AUDIO_BYTES: usize = 15 * 1024 * 1024; +pub(super) const MAX_BUFFERED_AUDIO_BYTES: usize = + INPUT_SAMPLE_RATE * BYTES_PER_SAMPLE * MAX_BUFFERED_SECONDS; +pub(super) const MIN_COMMIT_SAMPLES: usize = INPUT_SAMPLE_RATE * MIN_COMMIT_MILLISECONDS / 1_000; + +#[derive(Debug, Eq, PartialEq, thiserror::Error)] +pub(super) enum AudioError { + #[error("audio must be canonical padded Base64")] + InvalidBase64, + #[error("decoded audio exceeds the {max_bytes} byte append limit")] + AppendTooLarge { max_bytes: usize }, + #[error("PCM16 audio ended with an incomplete sample")] + IncompletePcm16Sample, + #[error("audio buffer exceeds {maximum_seconds} seconds")] + BufferTooLong { maximum_seconds: usize }, + #[error("committed audio must be at least {minimum_ms} milliseconds")] + CommitTooShort { minimum_ms: usize }, +} + +#[derive(Debug, PartialEq)] +pub(super) struct CommittedAudio { + samples: Vec, + input_samples: usize, +} + +impl CommittedAudio { + pub(super) fn samples(&self) -> &[f32] { + &self.samples + } + + #[cfg(test)] + pub(super) const fn input_samples(&self) -> usize { + self.input_samples + } + + #[allow(clippy::cast_precision_loss)] + pub(super) fn duration_seconds(&self) -> f64 { + self.input_samples as f64 / INPUT_SAMPLE_RATE as f64 + } +} + +#[derive(Debug, Default)] +pub(super) struct AudioBuffer { + input_bytes: usize, + input_samples: usize, + odd_byte: Option, + resampler: Resampler24To16, +} + +impl AudioBuffer { + pub(super) fn append_base64(&mut self, payload: &str) -> Result<(), AudioError> { + let bytes = decode_base64(payload)?; + let next_bytes = + self.input_bytes + .checked_add(bytes.len()) + .ok_or(AudioError::BufferTooLong { + maximum_seconds: MAX_BUFFERED_SECONDS, + })?; + if next_bytes > MAX_BUFFERED_AUDIO_BYTES { + return Err(AudioError::BufferTooLong { + maximum_seconds: MAX_BUFFERED_SECONDS, + }); + } + + self.input_bytes = next_bytes; + let mut bytes = bytes.into_iter(); + if let Some(low) = self.odd_byte.take() { + if let Some(high) = bytes.next() { + self.push_sample(i16::from_le_bytes([low, high])); + } else { + self.odd_byte = Some(low); + return Ok(()); + } + } + + while let Some(low) = bytes.next() { + if let Some(high) = bytes.next() { + self.push_sample(i16::from_le_bytes([low, high])); + } else { + self.odd_byte = Some(low); + } + } + Ok(()) + } + + #[cfg(test)] + pub(super) fn commit(&mut self) -> Result { + self.validate_commit()?; + Ok(self.commit_validated()) + } + + pub(super) fn commit_validated(&mut self) -> CommittedAudio { + self.resampler.flush(); + let samples = std::mem::take(&mut self.resampler.output); + let input_samples = self.input_samples; + self.clear(); + CommittedAudio { + samples, + input_samples, + } + } + + pub(super) fn validate_commit(&self) -> Result<(), AudioError> { + if self.odd_byte.is_some() { + return Err(AudioError::IncompletePcm16Sample); + } + if self.input_samples < MIN_COMMIT_SAMPLES { + return Err(AudioError::CommitTooShort { + minimum_ms: MIN_COMMIT_MILLISECONDS, + }); + } + Ok(()) + } + + pub(super) fn clear(&mut self) { + *self = Self::default(); + } + + pub(super) fn take_resampled(&mut self) -> Vec { + std::mem::take(&mut self.resampler.output) + } + + #[cfg(test)] + #[allow(clippy::cast_precision_loss)] + pub(super) fn buffered_duration_seconds(&self) -> f64 { + self.input_samples as f64 / INPUT_SAMPLE_RATE as f64 + } + + fn push_sample(&mut self, sample: i16) { + self.resampler.push(f32::from(sample) / 32_768.0); + self.input_samples += 1; + } +} + +pub(super) fn decode_base64(payload: &str) -> Result, AudioError> { + const MAX_BASE64_CHARS: usize = MAX_APPEND_AUDIO_BYTES.div_ceil(3) * 4; + if payload.len() > MAX_BASE64_CHARS { + return Err(AudioError::AppendTooLarge { + max_bytes: MAX_APPEND_AUDIO_BYTES, + }); + } + let decoded = base64::engine::general_purpose::STANDARD + .decode(payload) + .map_err(|_| AudioError::InvalidBase64)?; + if decoded.len() > MAX_APPEND_AUDIO_BYTES { + return Err(AudioError::AppendTooLarge { + max_bytes: MAX_APPEND_AUDIO_BYTES, + }); + } + Ok(decoded) +} + +#[derive(Debug, Default)] +struct Resampler24To16 { + input_index: usize, + next_output_twice: usize, + previous: Option, + output: Vec, + output_samples: usize, +} + +impl Resampler24To16 { + fn push(&mut self, sample: f32) { + let input_twice = self.input_index * 2; + if self.next_output_twice == input_twice { + self.emit(sample); + self.next_output_twice += 3; + } else if self.next_output_twice < input_twice { + let previous = self.previous.unwrap_or(sample); + self.emit(previous.midpoint(sample)); + self.next_output_twice += 3; + } + self.previous = Some(sample); + self.input_index += 1; + } + + fn flush(&mut self) { + if self.next_output_twice < self.input_index * 2 + && let Some(previous) = self.previous + { + self.emit(previous); + self.next_output_twice += 3; + } + debug_assert_eq!( + self.output_samples, + (self.input_index * OUTPUT_SAMPLE_RATE).div_ceil(INPUT_SAMPLE_RATE) + ); + } + + fn emit(&mut self, sample: f32) { + self.output.push(sample); + self.output_samples += 1; + } +} + +#[cfg(test)] +mod tests { + use base64::Engine as _; + use serde::Deserialize; + + use super::{ + AudioBuffer, AudioError, MAX_APPEND_AUDIO_BYTES, MAX_BUFFERED_AUDIO_BYTES, + MIN_COMMIT_SAMPLES, Resampler24To16, decode_base64, + }; + + #[derive(Deserialize)] + struct PcmFixture { + encoding: String, + sample_rate_hz: u32, + channels: u8, + samples: Vec, + bytes: Vec, + base64: String, + } + + fn fixture() -> PcmFixture { + serde_json::from_str(include_str!("../tests/fixtures/audio/pcm16le-24khz.json")) + .expect("audio fixture parses") + } + + fn pcm_bytes(samples: &[i16]) -> Vec { + samples + .iter() + .flat_map(|sample| sample.to_le_bytes()) + .collect() + } + + fn encoded(bytes: &[u8]) -> String { + base64::engine::general_purpose::STANDARD.encode(bytes) + } + + #[test] + fn language_neutral_fixture_pins_exact_pcm16le_bytes() { + let fixture = fixture(); + assert_eq!(fixture.encoding, "pcm_s16le"); + assert_eq!(fixture.sample_rate_hz, 24_000); + assert_eq!(fixture.channels, 1); + assert_eq!(fixture.bytes, pcm_bytes(&fixture.samples)); + assert_eq!( + decode_base64(&fixture.base64).expect("fixture Base64 decodes"), + fixture.bytes + ); + } + + #[test] + fn base64_rejects_invalid_and_noncanonical_encodings_and_decoded_oversize() { + assert_eq!(decode_base64("%%%"), Err(AudioError::InvalidBase64)); + assert_eq!(decode_base64("YQ"), Err(AudioError::InvalidBase64)); + for alias in [ + "YR==", "YS==", "YT==", "YU==", "YV==", "YW==", "YX==", "YY==", "YZ==", "Ya==", "Yb==", + "Yc==", "Yd==", "Ye==", "Yf==", "YWJ=", "YWK=", "YWL=", "YQ===", "YWI==", "YWJj=", + ] { + assert_eq!( + decode_base64(alias), + Err(AudioError::InvalidBase64), + "{alias}" + ); + } + let at_limit = vec![0_u8; MAX_APPEND_AUDIO_BYTES]; + assert_eq!( + decode_base64(&encoded(&at_limit)) + .expect("the exact append limit decodes") + .len(), + MAX_APPEND_AUDIO_BYTES + ); + let over_limit = vec![0_u8; MAX_APPEND_AUDIO_BYTES + 1]; + assert_eq!( + decode_base64(&encoded(&over_limit)), + Err(AudioError::AppendTooLarge { + max_bytes: MAX_APPEND_AUDIO_BYTES, + }) + ); + } + + #[test] + fn odd_byte_carry_and_resampling_match_unsplit_input() { + let input = (0..MIN_COMMIT_SAMPLES + 5) + .map(|index| i16::try_from(index % 1024).expect("fixture sample fits") - 512) + .collect::>(); + let bytes = pcm_bytes(&input); + let mut whole = AudioBuffer::default(); + whole + .append_base64(&encoded(&bytes)) + .expect("whole append succeeds"); + let expected = whole.commit().expect("whole commit succeeds"); + for split in [1, 2, 3, 47, bytes.len() - 1] { + let mut chunked = AudioBuffer::default(); + chunked + .append_base64(&encoded(&bytes[..split])) + .expect("first chunk succeeds"); + chunked + .append_base64(&encoded(&bytes[split..])) + .expect("second chunk succeeds"); + let actual = chunked.commit().expect("chunked commit succeeds"); + assert_eq!(actual.samples(), expected.samples(), "split at {split}"); + assert_eq!(actual.input_samples(), expected.input_samples()); + assert!((actual.duration_seconds() - expected.duration_seconds()).abs() < f64::EPSILON); + } + } + + #[test] + fn resampler_uses_one_continuous_linear_timeline() { + let mut resampler = Resampler24To16::default(); + for sample in [0.0, 2.0, 4.0, 6.0, 8.0] { + resampler.push(sample); + } + resampler.flush(); + assert_eq!(resampler.output, [0.0, 3.0, 6.0, 8.0]); + } + + #[test] + fn commit_flushes_the_last_resampler_position() { + let samples = vec![i16::MIN; MIN_COMMIT_SAMPLES + 1]; + let mut audio = AudioBuffer::default(); + audio + .append_base64(&encoded(&pcm_bytes(&samples))) + .expect("append succeeds"); + let committed = audio.commit().expect("commit succeeds"); + assert_eq!(committed.samples().len(), (samples.len() * 2).div_ceil(3)); + assert!( + committed + .samples() + .iter() + .all(|sample| (*sample - -1.0).abs() < f32::EPSILON) + ); + } + + #[test] + fn clear_discards_odd_byte_resampler_and_duration_state() { + let mut reused = AudioBuffer::default(); + reused + .append_base64(&encoded(&[0x7f, 0x01, 0x80])) + .expect("partial append succeeds"); + reused.clear(); + let clean_samples = vec![123_i16; MIN_COMMIT_SAMPLES]; + let clean_bytes = pcm_bytes(&clean_samples); + reused + .append_base64(&encoded(&clean_bytes)) + .expect("append after clear succeeds"); + let mut fresh = AudioBuffer::default(); + fresh + .append_base64(&encoded(&clean_bytes)) + .expect("fresh append succeeds"); + assert_eq!( + reused.commit().expect("reused commit succeeds"), + fresh.commit().expect("fresh commit succeeds") + ); + } + + #[test] + fn duration_uses_complete_input_samples_and_commit_rejects_odd_pcm() { + let mut audio = AudioBuffer::default(); + let samples = vec![0_i16; MIN_COMMIT_SAMPLES]; + let mut bytes = pcm_bytes(&samples); + bytes.push(0xaa); + audio + .append_base64(&encoded(&bytes)) + .expect("append carries the odd byte"); + assert!((audio.buffered_duration_seconds() - 0.1).abs() < f64::EPSILON); + assert_eq!(audio.commit(), Err(AudioError::IncompletePcm16Sample)); + } + + #[test] + fn commit_enforces_the_minimum_duration() { + let mut audio = AudioBuffer::default(); + audio + .append_base64(&encoded(&pcm_bytes(&vec![0_i16; MIN_COMMIT_SAMPLES - 1]))) + .expect("short audio appends"); + + assert_eq!( + audio.commit(), + Err(AudioError::CommitTooShort { minimum_ms: 100 }) + ); + } + + #[test] + fn buffered_audio_accepts_thirty_seconds_and_rejects_one_more_sample() { + let exact = vec![0_u8; MAX_BUFFERED_AUDIO_BYTES]; + let mut audio = AudioBuffer::default(); + audio + .append_base64(&encoded(&exact)) + .expect("thirty seconds is accepted"); + assert!((audio.buffered_duration_seconds() - 30.0).abs() < f64::EPSILON); + + assert_eq!( + audio.append_base64(&encoded(&0_i16.to_le_bytes())), + Err(AudioError::BufferTooLong { + maximum_seconds: 30, + }) + ); + } +} diff --git a/crates/gateway-stt/src/batch.rs b/crates/gateway-stt/src/batch.rs new file mode 100644 index 00000000..b3fa0f72 --- /dev/null +++ b/crates/gateway-stt/src/batch.rs @@ -0,0 +1,347 @@ +//! OpenAI-compatible multipart transcription handling. + +use std::io::Cursor; + +use axum::extract::multipart::MultipartRejection; +use axum::extract::{Multipart, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use axum::{Json, Router}; +use gateway_stt_engine::{DecodeRequest, EnginePolicy}; +use serde::Serialize; + +use crate::artifacts::SpeechError; +use crate::generation::GenerationState; + +const MAX_AUDIO_BYTES: usize = 25 * 1024 * 1024; +const BODY_LIMIT: usize = MAX_AUDIO_BYTES + 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ResponseFormat { + Json, + VerboseJson, +} + +#[derive(Debug)] +struct TranscriptionForm { + file: Vec, + model: String, + language: Option, + format: ResponseFormat, + granularities: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TimestampGranularity { + Word, + Segment, +} + +fn default_granularities() -> Vec { + vec![TimestampGranularity::Segment] +} + +/// A basic OpenAI transcription response. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct JsonTranscription { + /// The decoded transcript. + pub text: String, +} + +/// One clip-level segment in a verbose transcription response. +#[derive(Debug, Clone, PartialEq, Serialize)] +struct TranscriptionSegment { + /// Zero-based segment identifier. + pub id: u32, + /// Segment start in seconds. + pub start: f64, + /// Segment end in seconds. + pub end: f64, + /// Text decoded for the segment. + pub text: String, +} + +/// An OpenAI verbose transcription response. +#[derive(Debug, Clone, PartialEq, Serialize)] +struct VerboseJsonTranscription { + /// Requested task name. + pub task: &'static str, + /// Detected or caller-supplied language. + pub language: String, + /// Audio duration in seconds. + pub duration: f64, + /// The decoded transcript. + pub text: String, + /// Clip-level segments when segment granularity was requested. + pub segments: Vec, + /// Word timestamps. The current engine exposes no word alignment, so this + /// array stays empty when word granularity is requested. + pub words: Vec, +} + +/// A successful transcription in the requested OpenAI JSON dialect. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(untagged)] +enum TranscriptionResponse { + /// The compact `json` response. + Json(JsonTranscription), + /// The `verbose_json` response. + VerboseJson(VerboseJsonTranscription), +} + +pub(crate) fn routes(state: GenerationState) -> Router { + Router::new() + .route("/v1/audio/transcriptions", post(handler)) + .layer(axum::extract::DefaultBodyLimit::max(BODY_LIMIT)) + .with_state(state) +} + +async fn handler( + State(state): State, + multipart: Result, +) -> Response { + let multipart = match multipart { + Ok(multipart) => multipart, + Err(error) => { + return openai_error_response( + StatusCode::BAD_REQUEST, + "invalid_request_error", + "malformed_request", + &format!("malformed request: {error}"), + ); + } + }; + match transcribe(&state, multipart).await { + Ok(response) => response, + Err(error) => error_response(&error), + } +} + +async fn transcribe( + state: &GenerationState, + multipart: Multipart, +) -> Result { + let form = parse_form(multipart).await?; + let Some((generation, mode)) = state.select(&form.model) else { + return Err(SpeechError::ModelNotFound(form.model)); + }; + let (samples, duration) = decode_wav(&form.file)?; + let text = generation + .decode(DecodeRequest::new( + mode, + samples, + generation.guidance().to_vec(), + String::new(), + )) + .await + .map_err(SpeechError::Inference)?; + Ok(axum::Json(response(form, text, duration)).into_response()) +} + +async fn parse_form(mut multipart: Multipart) -> Result { + let mut file = None; + let mut model = None; + let mut language = None; + let mut format = ResponseFormat::Json; + let mut granularities = default_granularities(); + while let Some(mut field) = multipart + .next_field() + .await + .map_err(SpeechError::Multipart)? + { + let Some(name) = field.name().map(str::to_owned) else { + continue; + }; + match name.as_str() { + "file" => { + let mut bytes = Vec::new(); + while let Some(chunk) = field.chunk().await.map_err(SpeechError::Multipart)? { + if bytes.len().saturating_add(chunk.len()) > MAX_AUDIO_BYTES { + return Err(SpeechError::FileTooLarge); + } + bytes.extend_from_slice(&chunk); + } + file = Some(bytes); + } + "model" => model = Some(field_text(field).await?), + "language" => language = Some(field_text(field).await?), + "response_format" => { + format = match field_text(field).await?.as_str() { + "json" => ResponseFormat::Json, + "verbose_json" => ResponseFormat::VerboseJson, + value => { + return Err(SpeechError::UnsupportedResponseFormat(value.to_owned())); + } + }; + } + "timestamp_granularities[]" => { + granularities.push(match field_text(field).await?.as_str() { + "word" => TimestampGranularity::Word, + "segment" => TimestampGranularity::Segment, + value => { + return Err(SpeechError::InvalidField { + field: "timestamp_granularities[]", + value: value.to_owned(), + }); + } + }); + } + "temperature" => { + let value = field_text(field).await?; + let parsed = value + .parse::() + .map_err(|_| SpeechError::InvalidField { + field: "temperature", + value: value.clone(), + })?; + if !parsed.is_finite() || parsed < 0.0 { + return Err(SpeechError::InvalidField { + field: "temperature", + value, + }); + } + } + // OpenAI-compatible hints accepted by the dialect. The current + // English whisper workers already own their prompt policy. + "prompt" => { + let _ignored = field_text(field).await?; + } + _ => {} + } + } + Ok(TranscriptionForm { + file: file.ok_or(SpeechError::MissingField("file"))?, + model: model.ok_or(SpeechError::MissingField("model"))?, + language, + format, + granularities, + }) +} + +async fn field_text(field: axum::extract::multipart::Field<'_>) -> Result { + field.text().await.map_err(SpeechError::Multipart) +} + +#[expect( + clippy::cast_precision_loss, + reason = "PCM normalization and clip duration intentionally convert bounded audio counts to floating point" +)] +fn decode_wav(bytes: &[u8]) -> Result<(Vec, f64), SpeechError> { + const SAMPLE_RATE_U32: u32 = 16_000; + let mut reader = + hound::WavReader::new(Cursor::new(bytes)).map_err(SpeechError::InvalidAudio)?; + let spec = reader.spec(); + if spec.channels != 1 || spec.sample_rate != SAMPLE_RATE_U32 { + return Err(SpeechError::UnsupportedAudio { + sample_rate: spec.sample_rate, + channels: spec.channels, + }); + } + let samples = match spec.sample_format { + hound::SampleFormat::Float => reader + .samples::() + .collect::, _>>() + .map_err(SpeechError::InvalidAudio)?, + hound::SampleFormat::Int => { + let denominator = 2_f32.powi(i32::from(spec.bits_per_sample.saturating_sub(1))); + reader + .samples::() + .map(|sample| { + sample + .map(|value| value as f32 / denominator) + .map_err(SpeechError::InvalidAudio) + }) + .collect::, _>>()? + } + }; + let duration = samples.len() as f64 / EnginePolicy::SAMPLE_RATE as f64; + Ok((samples, duration)) +} + +fn response(form: TranscriptionForm, text: String, duration: f64) -> TranscriptionResponse { + match form.format { + ResponseFormat::Json => TranscriptionResponse::Json(JsonTranscription { text }), + ResponseFormat::VerboseJson => { + let segments = if form.granularities.contains(&TimestampGranularity::Segment) { + vec![TranscriptionSegment { + id: 0, + start: 0.0, + end: duration, + text: text.clone(), + }] + } else { + Vec::new() + }; + TranscriptionResponse::VerboseJson(VerboseJsonTranscription { + task: "transcribe", + language: form.language.unwrap_or_else(|| "en".to_owned()), + duration, + text, + segments, + words: Vec::new(), + }) + } + } +} + +fn error_response(error: &SpeechError) -> Response { + let (status, kind, code) = if error.model_not_found().is_some() { + ( + StatusCode::NOT_FOUND, + "invalid_request_error", + "model_not_found", + ) + } else if error.is_file_too_large() { + ( + StatusCode::PAYLOAD_TOO_LARGE, + "invalid_request_error", + "file_too_large", + ) + } else if error.is_inference() { + ( + StatusCode::INTERNAL_SERVER_ERROR, + "server_error", + "transcription_error", + ) + } else { + ( + StatusCode::BAD_REQUEST, + "invalid_request_error", + "malformed_request", + ) + }; + let message = if error.is_inference() { + "transcription failed".to_owned() + } else if error.model_not_found().is_some() || error.is_file_too_large() { + error.to_string() + } else { + format!("malformed request: {error}") + }; + openai_error_response(status, kind, code, &message) +} + +fn openai_error_response( + status: StatusCode, + kind: &'static str, + code: &'static str, + message: &str, +) -> Response { + ( + status, + Json(serde_json::json!({ + "error": { + "message": message, + "type": kind, + "code": code, + } + })), + ) + .into_response() +} + +#[cfg(all(test, not(miri)))] +mod native_tests; + +#[cfg(test)] +mod tests; diff --git a/crates/gateway-stt/src/batch/native_tests.rs b/crates/gateway-stt/src/batch/native_tests.rs new file mode 100644 index 00000000..1127a49c --- /dev/null +++ b/crates/gateway-stt/src/batch/native_tests.rs @@ -0,0 +1,113 @@ +//! Native batch route coverage. + +#![expect( + clippy::expect_used, + reason = "native route fixtures fail with the invariant named" +)] + +use std::io::Cursor; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tower::ServiceExt as _; + +mod native_runtime { + #[rustfmt::skip] + include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/common/native_runtime.rs")); +} + +fn wav_f32(samples: &[f32]) -> Vec { + let mut bytes = Cursor::new(Vec::new()); + { + let mut writer = hound::WavWriter::new( + &mut bytes, + hound::WavSpec { + channels: 1, + sample_rate: 16_000, + bits_per_sample: 32, + sample_format: hound::SampleFormat::Float, + }, + ) + .expect("writer builds"); + for sample in samples { + writer.write_sample(*sample).expect("sample writes"); + } + writer.finalize().expect("WAV finalizes"); + } + bytes.into_inner() +} + +fn multipart_body(file: &[u8]) -> (String, Vec) { + const BOUNDARY: &str = "gateway-stt-boundary"; + let mut body = format!( + "--{BOUNDARY}\r\n\ + Content-Disposition: form-data; name=\"model\"\r\n\r\n\ + speech\r\n\ + --{BOUNDARY}\r\n\ + Content-Disposition: form-data; name=\"response_format\"\r\n\r\n\ + verbose_json\r\n\ + --{BOUNDARY}\r\n\ + Content-Disposition: form-data; name=\"timestamp_granularities[]\"\r\n\r\n\ + segment\r\n\ + --{BOUNDARY}\r\n\ + Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n\ + Content-Type: audio/wav\r\n\r\n" + ) + .into_bytes(); + body.extend_from_slice(file); + body.extend_from_slice(format!("\r\n--{BOUNDARY}--\r\n").as_bytes()); + (BOUNDARY.to_owned(), body) +} + +#[tokio::test] +#[ignore = "requires whisper test fixtures (tests/fixtures/)"] +async fn verbose_round_trip_accepts_literal_timestamp_granularities_field() { + let dir = tempfile::tempdir().expect("tempdir"); + let source = crate::test_fixtures::require_model() + .display() + .to_string() + .replace('\\', "/"); + let cache = dir.path().display().to_string().replace('\\', "/"); + let catalog = gateway_config::Config::from_toml_str(&format!( + "config-version = 2\n\ + [server]\nbind = \"127.0.0.1:0\"\napi_key = \"k\"\n\ + [local]\ncache_dir = {cache:?}\n\ + [workshop]\n\ + [[stt_model]]\nname = \"speech\"\nrole = \"interim\"\nsource = {source:?}\n\ + vram_gb = 1.0\n\ + [[profile]]\nname = \"work\"\nmodels = [\"speech\"]\n" + )) + .expect("catalog parses"); + let config = catalog + .select_profile(&gateway_config::ProfileName::parse("work").expect("name")) + .expect("profile selects"); + let service = native_runtime::start(config); + let (boundary, body) = multipart_body(&wav_f32(&crate::test_fixtures::jfk_samples())); + let response = service + .routes() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/audio/transcriptions") + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .expect("request builds"), + ) + .await + .expect("route answers"); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body reads"); + let json: serde_json::Value = serde_json::from_slice(&body).expect("body is JSON"); + assert!( + json["text"] + .as_str() + .is_some_and(|text| text.to_lowercase().contains("country")) + ); + assert_eq!(json["segments"][0]["start"], 0.0); + native_runtime::shutdown(service); +} diff --git a/crates/gateway-stt/src/batch/tests.rs b/crates/gateway-stt/src/batch/tests.rs new file mode 100644 index 00000000..4bb369ff --- /dev/null +++ b/crates/gateway-stt/src/batch/tests.rs @@ -0,0 +1,160 @@ +use super::*; +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use tower::ServiceExt; + +fn wav(samples: &[i16]) -> Vec { + let mut bytes = Cursor::new(Vec::new()); + { + let mut writer = hound::WavWriter::new( + &mut bytes, + hound::WavSpec { + channels: 1, + sample_rate: 16_000, + bits_per_sample: 16, + sample_format: hound::SampleFormat::Int, + }, + ) + .expect("writer builds"); + for sample in samples { + writer.write_sample(*sample).expect("sample writes"); + } + writer.finalize().expect("WAV finalizes"); + } + bytes.into_inner() +} + +#[test] +fn wav_decode_accepts_the_stt_wire_sample_rate() { + let (samples, duration) = decode_wav(&wav(&[0, i16::MAX])).expect("WAV decodes"); + assert_eq!(samples.len(), 2); + assert!(samples[1] > 0.99); + assert!((duration - 2.0 / 16_000.0).abs() < f64::EPSILON); +} + +#[test] +fn verbose_json_honors_segment_granularity() { + let response = response( + TranscriptionForm { + file: Vec::new(), + model: "speech".to_owned(), + language: Some("en".to_owned()), + format: ResponseFormat::VerboseJson, + granularities: vec![TimestampGranularity::Segment], + }, + "hello".to_owned(), + 1.25, + ); + let json = serde_json::to_value(response).expect("response serializes"); + assert_eq!(json["text"], "hello"); + assert_eq!(json["duration"], 1.25); + assert_eq!(json["segments"][0]["end"], 1.25); +} + +#[test] +fn verbose_json_defaults_to_segment_timestamps() { + let granularities = default_granularities(); + let response = response( + TranscriptionForm { + file: Vec::new(), + model: "speech".to_owned(), + language: None, + format: ResponseFormat::VerboseJson, + granularities, + }, + "hello".to_owned(), + 1.25, + ); + let json = serde_json::to_value(response).expect("response serializes"); + assert_eq!(json["segments"][0]["text"], "hello"); +} + +#[test] +fn compact_json_contains_only_text() { + let response = response( + TranscriptionForm { + file: Vec::new(), + model: "speech".to_owned(), + language: None, + format: ResponseFormat::Json, + granularities: Vec::new(), + }, + "hello".to_owned(), + 1.0, + ); + assert_eq!( + serde_json::to_value(response).expect("response serializes"), + serde_json::json!({"text": "hello"}) + ); +} + +fn multipart_body(file: &[u8], fields: &[(&str, &str)]) -> (String, Vec) { + const BOUNDARY: &str = "gateway-stt-boundary"; + let mut body = Vec::new(); + for (name, value) in fields { + body.extend_from_slice(format!("--{BOUNDARY}\r\n").as_bytes()); + body.extend_from_slice( + format!("Content-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n") + .as_bytes(), + ); + } + body.extend_from_slice(format!("--{BOUNDARY}\r\n").as_bytes()); + body.extend_from_slice( + b"Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n\ + Content-Type: audio/wav\r\n\r\n", + ); + body.extend_from_slice(file); + body.extend_from_slice(format!("\r\n--{BOUNDARY}--\r\n").as_bytes()); + (BOUNDARY.to_owned(), body) +} + +#[tokio::test] +async fn an_unloaded_model_is_not_found() { + let (boundary, body) = multipart_body( + &wav(&vec![0; 16_000]), + &[("model", "not-loaded"), ("response_format", "json")], + ); + let response = routes(GenerationState::default()) + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/audio/transcriptions") + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .expect("request builds"), + ) + .await + .expect("route answers"); + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn an_audio_file_over_25_mib_is_rejected_before_decode() { + let oversized = vec![0_u8; MAX_AUDIO_BYTES + 1]; + let (boundary, body) = multipart_body(&oversized, &[("model", "speech")]); + let response = routes(GenerationState::default()) + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/audio/transcriptions") + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .expect("request builds"), + ) + .await + .expect("route answers"); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body reads"); + let json: serde_json::Value = serde_json::from_slice(&body).expect("body is JSON"); + assert_eq!( + json["error"]["message"], + "audio file exceeds the 25 MiB limit" + ); +} diff --git a/crates/gateway-stt/src/generation.rs b/crates/gateway-stt/src/generation.rs new file mode 100644 index 00000000..8294a4a3 --- /dev/null +++ b/crates/gateway-stt/src/generation.rs @@ -0,0 +1,452 @@ +//! Atomic publication and owned admission for one speech generation. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, PoisonError, RwLock, Weak}; +use std::time::{Duration, Instant}; + +use gateway_stt_backend_whisper::{WhisperConfig, WhisperModelFactory}; +#[cfg(feature = "test-fixtures")] +use gateway_stt_engine::ModelFactory; +use gateway_stt_engine::{DecodeMode, EnginePolicy}; + +use crate::artifacts::{PreparedSpeech, SpeechError}; +#[cfg(feature = "test-fixtures")] +use crate::model::ModelNames; +use crate::model::SpeechModelInfo; +use crate::replacement::{DrainOutcome, ReplacementCoordinator, ReplacementPermit}; +use crate::status::SpeechStatus; + +mod lease; +mod snapshot; + +#[cfg(feature = "test-fixtures")] +pub(crate) use lease::GenerationJob; +pub(crate) use lease::GenerationLease; +use snapshot::{Backend, Generation, GenerationSpec}; + +const GENERATION_QUIESCENCE_TIMEOUT: Duration = Duration::from_secs(30); + +/// One staged generation token whose internals remain service-owned. +#[derive(Debug)] +pub struct SpeechReplacement { + owner: Weak, + generation: Option, + rollback: Option, + permit: ReplacementPermit, +} + +#[derive(Debug)] +struct Shared { + publication: RwLock, + next_generation: AtomicU64, + replacements: Arc, +} + +#[derive(Debug, Default)] +struct Publication { + active: Option>, + configured: bool, +} + +/// Cloneable internal state used by service methods and private handlers. +#[derive(Debug, Clone)] +pub(crate) struct GenerationState { + shared: Arc, +} + +impl Default for GenerationState { + fn default() -> Self { + Self { + shared: Arc::new(Shared { + publication: RwLock::new(Publication::default()), + next_generation: AtomicU64::new(1), + replacements: Arc::new(ReplacementCoordinator::default()), + }), + } + } +} + +impl GenerationState { + pub(crate) fn stage(&self, prepared: PreparedSpeech) -> Result { + let deadline = Instant::now() + .checked_add(GENERATION_QUIESCENCE_TIMEOUT) + .ok_or(SpeechError::QuiescenceDeadline)?; + self.stage_until(prepared, deadline) + } + + pub(crate) fn stage_until( + &self, + prepared: PreparedSpeech, + deadline: Instant, + ) -> Result { + self.replace_with_until(deadline, move |id, startup_timeout| { + prepared + .generation + .map(|prepared| { + let backend_config = WhisperConfig::new( + prepared.library, + prepared.interim_model, + prepared.final_model, + prepared.progress, + ); + let factory = + WhisperModelFactory::new(backend_config).map_err(SpeechError::Engine)?; + let policy = EnginePolicy::new( + prepared.window_seconds, + prepared.interval_ms, + factory.gpu_available(), + ) + .map_err(SpeechError::Engine)? + .with_startup_timeout(startup_timeout); + Generation::from_factory( + id, + Backend::Whisper, + factory, + policy, + prepared.names, + prepared.guidance, + ) + }) + .transpose() + }) + } + + #[cfg(feature = "test-fixtures")] + pub(crate) fn stage_scripted( + &self, + factory: impl ModelFactory, + final_model: Option, + gpu_available: bool, + timeout: Duration, + ) -> Result { + let deadline = Instant::now() + .checked_add(timeout) + .ok_or(SpeechError::QuiescenceDeadline)?; + let policy = EnginePolicy::new(15, 500, gpu_available).map_err(SpeechError::Engine)?; + self.replace_with_until(deadline, move |id, startup_timeout| { + Generation::from_factory( + id, + Backend::Scripted, + factory, + policy.with_startup_timeout(startup_timeout), + ModelNames::scripted(final_model.is_some()), + Vec::new(), + ) + .map(Some) + }) + } + + #[cfg(feature = "test-fixtures")] + pub(crate) fn stage_scripted_with_policy( + &self, + factory: impl ModelFactory, + policy: EnginePolicy, + ) -> Result { + self.replace_with(GENERATION_QUIESCENCE_TIMEOUT, move |id| { + GenerationSpec::scripted_inferred(factory, policy) + .build(id) + .map(Some) + }) + } + + pub(crate) fn commit(&self, replacement: SpeechReplacement) -> Result<(), SpeechError> { + let Some(owner) = replacement.owner.upgrade() else { + return Err(SpeechError::ReplacementOwner); + }; + if !Arc::ptr_eq(&owner, &self.shared) { + return Err(SpeechError::ReplacementOwner); + } + + let mut replacement = replacement; + let published = replacement.generation.take().map(Arc::new); + let configured = published.is_some(); + let committed = replacement.permit.with_current(|| { + let mut publication = self + .shared + .publication + .write() + .unwrap_or_else(PoisonError::into_inner); + if publication.active.is_some() { + return false; + } + publication.active = published; + publication.configured = configured; + true + }); + match committed { + Some(true) => replacement.rollback = None, + Some(false) => return Err(SpeechError::GenerationActive), + None => return Err(SpeechError::ReplacementInvalidated), + } + Ok(()) + } + + pub(crate) fn abort(&self, mut replacement: SpeechReplacement) -> Result<(), SpeechError> { + let Some(owner) = replacement.owner.upgrade() else { + return Err(SpeechError::ReplacementOwner); + }; + if !Arc::ptr_eq(&owner, &self.shared) { + return Err(SpeechError::ReplacementOwner); + } + replacement.rollback() + } + + pub(crate) fn shutdown(&self) { + let _shutdown = self.shared.replacements.begin_shutdown(); + let generation = self + .shared + .publication + .read() + .unwrap_or_else(PoisonError::into_inner) + .active + .as_ref() + .map(Arc::clone); + let Some(generation) = generation else { + return; + }; + generation.admission.shutdown(); + generation.admission.wait_until_idle(); + let retired = self + .shared + .publication + .write() + .unwrap_or_else(PoisonError::into_inner) + .active + .take_if(|active| Arc::ptr_eq(active, &generation)); + drop(generation); + if let Some(retired) = retired + && let Err(error) = retired.shutdown() + { + tracing::error!(error = %error, "speech generation shutdown failed"); + } + } + + pub(crate) fn active(&self) -> Option { + let publication = self + .shared + .publication + .read() + .unwrap_or_else(PoisonError::into_inner); + let generation = publication.active.as_ref()?; + let admission = generation.admission.admit()?; + Some(GenerationLease::new(Arc::clone(generation), admission)) + } + + pub(crate) fn select(&self, name: &str) -> Option<(GenerationLease, DecodeMode)> { + let generation = self.active()?; + let mode = generation.select(name)?; + Some((generation, mode)) + } + + pub(crate) fn status(&self) -> SpeechStatus { + let publication = self + .shared + .publication + .read() + .unwrap_or_else(PoisonError::into_inner); + publication + .active + .as_deref() + .filter(|generation| generation.admission.is_open()) + .map_or_else( + || SpeechStatus::unready(publication.configured), + Generation::status, + ) + } + + pub(crate) fn models(&self) -> Vec { + let publication = self + .shared + .publication + .read() + .unwrap_or_else(PoisonError::into_inner); + publication + .active + .as_deref() + .filter(|generation| generation.admission.is_open()) + .map_or_else(Vec::new, Generation::models) + } + + #[cfg(feature = "test-fixtures")] + pub(crate) fn counts(&self) -> Option<(usize, usize)> { + self.shared + .publication + .read() + .unwrap_or_else(PoisonError::into_inner) + .active + .as_ref() + .map(|generation| generation.admission.counts()) + } + + #[cfg(feature = "test-fixtures")] + fn replace_with( + &self, + timeout: Duration, + build: impl FnOnce(u64) -> Result, SpeechError>, + ) -> Result { + let deadline = Instant::now() + .checked_add(timeout) + .ok_or(SpeechError::QuiescenceDeadline)?; + self.replace_with_until(deadline, move |id, _startup_timeout| build(id)) + } + + fn replace_with_until( + &self, + deadline: Instant, + build: impl FnOnce(u64, Duration) -> Result, SpeechError>, + ) -> Result { + let permit = self.shared.replacements.acquire(); + let rollback = self.quiesce(&permit, deadline)?; + if !permit.is_current() { + return Err(SpeechError::ReplacementInvalidated); + } + let startup_timeout = deadline.saturating_duration_since(Instant::now()); + let generation = match build(self.next_id(), startup_timeout) { + Ok(generation) => generation, + Err(failure) => { + if failure.is_non_preemptible_startup_timeout() { + return Err(failure); + } + if let Some(rollback) = rollback + && let Err(rollback) = restore_generation(&self.shared, &permit, &rollback) + { + return Err(SpeechError::Rollback { + failure: Box::new(failure), + rollback: Box::new(rollback), + }); + } + return Err(failure); + } + }; + if !permit.is_current() { + return Err(SpeechError::ReplacementInvalidated); + } + Ok(SpeechReplacement { + owner: Arc::downgrade(&self.shared), + generation, + rollback, + permit, + }) + } + + fn quiesce( + &self, + permit: &ReplacementPermit, + deadline: Instant, + ) -> Result, SpeechError> { + let generation = self + .shared + .publication + .read() + .unwrap_or_else(PoisonError::into_inner) + .active + .as_ref() + .map(Arc::clone); + let Some(generation) = generation else { + return Ok(None); + }; + let close = permit + .with_current(|| { + let close = generation.admission.close()?; + Some(close) + }) + .flatten() + .ok_or(SpeechError::ReplacementInvalidated)?; + match generation.admission.wait_for_idle(&close, deadline) { + DrainOutcome::TimedOut => { + let reopened = permit + .with_current(|| generation.admission.reopen(&close)) + .unwrap_or(false); + if reopened { + Err(SpeechError::QuiescenceDeadline) + } else { + Err(SpeechError::ReplacementInvalidated) + } + } + DrainOutcome::Invalidated => Err(SpeechError::ReplacementInvalidated), + DrainOutcome::Idle => { + let restart = generation.restart_spec(); + let retired = permit + .with_current(|| { + self.shared + .publication + .write() + .unwrap_or_else(PoisonError::into_inner) + .active + .take_if(|active| Arc::ptr_eq(active, &generation)) + }) + .flatten() + .ok_or(SpeechError::ReplacementInvalidated)?; + drop(generation); + retired.shutdown()?; + Ok(Some(restart)) + } + } + } + + fn next_id(&self) -> u64 { + self.shared.next_generation.fetch_add(1, Ordering::Relaxed) + } +} + +fn restore_generation( + shared: &Arc, + permit: &ReplacementPermit, + rollback: &GenerationSpec, +) -> Result<(), SpeechError> { + if !permit.is_current() { + return Err(SpeechError::ReplacementInvalidated); + } + let id = shared.next_generation.fetch_add(1, Ordering::Relaxed); + let generation = Arc::new(rollback.build(id)?); + let restored = permit + .with_current(|| { + let mut publication = shared + .publication + .write() + .unwrap_or_else(PoisonError::into_inner); + if publication.active.is_some() { + return false; + } + publication.active = Some(generation); + true + }) + .unwrap_or(false); + if !restored { + return Err(SpeechError::ReplacementInvalidated); + } + Ok(()) +} + +impl SpeechReplacement { + fn rollback(&mut self) -> Result<(), SpeechError> { + let Some(owner) = self.owner.upgrade() else { + return Err(SpeechError::ReplacementOwner); + }; + let cleanup = self + .generation + .take() + .map_or(Ok(()), |generation| generation.shutdown()); + let reconstruction = self.rollback.take().map_or(Ok(()), |rollback| { + restore_generation(&owner, &self.permit, &rollback) + }); + match (cleanup, reconstruction) { + (Err(failure), Err(rollback)) => Err(SpeechError::Rollback { + failure: Box::new(failure), + rollback: Box::new(rollback), + }), + (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error), + (Ok(()), Ok(())) => Ok(()), + } + } +} + +impl Drop for SpeechReplacement { + fn drop(&mut self) { + if (self.generation.is_some() || self.rollback.is_some()) + && let Err(error) = self.rollback() + { + tracing::error!(error = %error, "speech replacement rollback failed"); + } + } +} diff --git a/crates/gateway-stt/src/generation/lease.rs b/crates/gateway-stt/src/generation/lease.rs new file mode 100644 index 00000000..612a7708 --- /dev/null +++ b/crates/gateway-stt/src/generation/lease.rs @@ -0,0 +1,130 @@ +//! Request and worker ownership for one admitted generation. + +use std::sync::Arc; +use std::time::Duration; + +use gateway_stt_engine::{DecodeMode, DecodeRequest, TranscribeError}; + +use crate::replacement::{AdmissionLease, JobLease, SessionEpoch}; + +use super::snapshot::Generation; + +/// One explicitly counted request or session borrowing a complete generation. +#[derive(Debug)] +pub(crate) struct GenerationLease { + generation: Option>, + admission: AdmissionLease, +} + +impl Clone for GenerationLease { + fn clone(&self) -> Self { + Self { + generation: self.generation.as_ref().map(Arc::clone), + admission: self.admission.clone(), + } + } +} + +impl Drop for GenerationLease { + fn drop(&mut self) { + drop(self.generation.take()); + } +} + +impl GenerationLease { + pub(super) fn new(generation: Arc, admission: AdmissionLease) -> Self { + Self { + generation: Some(generation), + admission, + } + } + + fn generation(&self) -> &Generation { + self.generation + .as_deref() + .unwrap_or_else(|| unreachable!("generation lease is live until drop")) + } + + pub(crate) fn guidance(&self) -> &[String] { + &self.generation().guidance + } + + pub(super) fn select(&self, name: &str) -> Option { + self.generation().select(name) + } + + pub(crate) fn has_final_pass(&self) -> bool { + self.generation().has_final_pass() + } + + pub(crate) fn window_samples(&self) -> usize { + self.generation().window_samples() + } + + pub(crate) fn interval(&self) -> Duration { + self.generation().interval() + } + + pub(crate) fn epoch(&self) -> &SessionEpoch { + self.admission.epoch() + } + + pub(crate) async fn cancelled(&self) { + self.epoch().cancelled().await; + } + + pub(crate) fn own_job(&self) -> Option { + let ownership = self.admission.own_job()?; + Some(GenerationJob { + generation: self.generation.as_ref().map(Arc::clone), + ownership: Some(ownership), + }) + } + + pub(crate) async fn decode(&self, request: DecodeRequest) -> Result { + let job = self.own_job().ok_or_else(generation_unavailable)?; + let epoch = self.epoch().clone(); + let (reply, result) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + if reply.is_closed() { + return; + } + let result = job.generation().decode(request).await; + drop(job); + drop(reply.send(result)); + }); + tokio::select! { + biased; + () = epoch.cancelled() => Err(generation_unavailable()), + result = result => result.unwrap_or_else(|_| Err(generation_unavailable())), + } + } +} + +/// One worker job whose count outlives cancellation of its request future. +#[derive(Debug)] +pub(crate) struct GenerationJob { + generation: Option>, + ownership: Option, +} + +impl GenerationJob { + fn generation(&self) -> &Generation { + self.generation + .as_deref() + .unwrap_or_else(|| unreachable!("generation job is live until drop")) + } +} + +impl Drop for GenerationJob { + fn drop(&mut self) { + drop(self.generation.take()); + drop(self.ownership.take()); + } +} + +fn generation_unavailable() -> TranscribeError { + TranscribeError::inference(std::io::Error::other( + "speech generation admission is closed", + )) +} diff --git a/crates/gateway-stt/src/generation/snapshot.rs b/crates/gateway-stt/src/generation/snapshot.rs new file mode 100644 index 00000000..ee0c4cc2 --- /dev/null +++ b/crates/gateway-stt/src/generation/snapshot.rs @@ -0,0 +1,158 @@ +//! One complete engine generation and its immutable published facts. + +use std::sync::Arc; +use std::time::Duration; + +use gateway_stt_engine::{ + DecodeMode, DecodeRequest, EnginePolicy, ModelFactory, SttEngine, TranscribeError, +}; + +use crate::artifacts::SpeechError; +use crate::model::{ModelNames, SpeechModelInfo}; +use crate::replacement::AdmissionGate; +use crate::status::SpeechStatus; + +#[derive(Debug, Clone, Copy)] +pub(super) enum Backend { + Whisper, + #[cfg(feature = "test-fixtures")] + Scripted, +} + +#[derive(Debug)] +struct SharedFactory(Arc); + +impl ModelFactory for SharedFactory { + fn create( + &self, + mode: DecodeMode, + ) -> Result>, TranscribeError> { + self.0.create(mode) + } +} + +#[derive(Clone, Debug)] +pub(super) struct GenerationSpec { + backend: Backend, + factory: Arc, + policy: EnginePolicy, + names: ModelNames, + guidance: Vec, + infer_scripted_final: bool, +} + +impl GenerationSpec { + pub(super) fn new( + backend: Backend, + factory: impl ModelFactory, + policy: EnginePolicy, + names: ModelNames, + guidance: Vec, + ) -> Self { + Self { + backend, + factory: Arc::new(factory), + policy, + names, + guidance, + infer_scripted_final: false, + } + } + + #[cfg(feature = "test-fixtures")] + pub(super) fn scripted_inferred(factory: impl ModelFactory, policy: EnginePolicy) -> Self { + let mut spec = Self::new( + Backend::Scripted, + factory, + policy, + ModelNames::scripted(false), + Vec::new(), + ); + spec.infer_scripted_final = true; + spec + } + + pub(super) fn build(&self, id: u64) -> Result { + let engine = SttEngine::new(SharedFactory(Arc::clone(&self.factory)), self.policy) + .map_err(SpeechError::Engine)?; + let names = if self.infer_scripted_final { + ModelNames::scripted(engine.has_final_pass()) + } else { + self.names.clone() + }; + Ok(Generation { + id, + backend: self.backend, + engine, + names, + guidance: self.guidance.clone().into(), + admission: Arc::new(AdmissionGate::default()), + restart: self.clone(), + }) + } +} + +#[derive(Debug)] +pub(super) struct Generation { + pub(super) id: u64, + backend: Backend, + engine: SttEngine, + names: ModelNames, + pub(super) guidance: Arc<[String]>, + pub(super) admission: Arc, + restart: GenerationSpec, +} + +impl Generation { + pub(super) fn from_factory( + id: u64, + backend: Backend, + factory: impl ModelFactory, + policy: EnginePolicy, + names: ModelNames, + guidance: Vec, + ) -> Result { + GenerationSpec::new(backend, factory, policy, names, guidance).build(id) + } + + pub(super) fn restart_spec(&self) -> GenerationSpec { + self.restart.clone() + } + + pub(super) fn shutdown(&self) -> Result<(), SpeechError> { + self.engine.shutdown().map_err(SpeechError::Engine) + } + + pub(super) fn status(&self) -> SpeechStatus { + let gpu = match self.backend { + Backend::Whisper => self.engine.gpu_transcription_available(), + #[cfg(feature = "test-fixtures")] + Backend::Scripted => self.engine.gpu_transcription_available(), + }; + SpeechStatus::active(gpu, self.id) + } + + pub(super) fn models(&self) -> Vec { + self.names.infos() + } + + pub(super) fn select(&self, name: &str) -> Option { + self.names.select(name) + } + + pub(super) fn has_final_pass(&self) -> bool { + self.engine.has_final_pass() + } + + pub(super) fn window_samples(&self) -> usize { + self.engine.window_samples() + } + + pub(super) fn interval(&self) -> Duration { + self.engine.interval() + } + + pub(super) async fn decode(&self, request: DecodeRequest) -> Result { + self.engine.decode(request).await + } +} diff --git a/crates/gateway-stt/src/lib.rs b/crates/gateway-stt/src/lib.rs index 2adb6b0b..03d58e47 100644 --- a/crates/gateway-stt/src/lib.rs +++ b/crates/gateway-stt/src/lib.rs @@ -1,16 +1,37 @@ -//! Gateway-owned speech-to-text runtime and HTTP endpoints. +//! Gateway-owned speech facade and HTTP endpoints. //! -//! [`SttRuntime`] provisions the selected profile's speech models through -//! [`ArtifactStore`](gateway_local::artifacts::ArtifactStore), -//! loads [`SttEngine`](gateway_transcribe::SttEngine), and unloads it on -//! profile switch. [`gateway_routes`] serves the gateway's streaming STT -//! surface, [`stt_routes`] remains the Workshop-listener attachment seam, -//! and [`transcribe`] implements OpenAI-compatible multipart transcription. +//! [`SpeechService`] owns artifact preparation, complete generation +//! publication, batch transcription, and Realtime transcription. -mod api; -mod runtime; -mod stt; +mod artifacts; +mod audio; +mod batch; +mod generation; +mod model; +mod realtime; +mod replacement; +mod segment; +mod service; +mod status; +mod take; +#[cfg(all(test, not(feature = "test-fixtures")))] +mod test_fixtures; +#[cfg(feature = "test-fixtures")] +pub mod test_fixtures; -pub use api::{MAX_AUDIO_BYTES, TranscriptionError, transcribe}; -pub use runtime::{SttRuntime, SttRuntimeError, SttState}; -pub use stt::{gateway_routes, routes as stt_routes}; +pub use artifacts::{PreparedSpeech, SpeechError}; +pub use generation::SpeechReplacement; +pub use model::SpeechModelInfo; +pub use service::SpeechService; +pub use status::SpeechStatus; + +#[cfg(all(test, miri))] +mod miri_tests { + use super::SpeechService; + + #[test] + fn miri_facade_target_executes_without_native_route_fixtures() { + let service = SpeechService::new(); + assert!(!service.status().ready()); + } +} diff --git a/crates/gateway-stt/src/model.rs b/crates/gateway-stt/src/model.rs new file mode 100644 index 00000000..3a7671d9 --- /dev/null +++ b/crates/gateway-stt/src/model.rs @@ -0,0 +1,105 @@ +//! Speech-model identity advertised by one active generation. + +use gateway_stt_engine::DecodeMode; + +pub(crate) const REALTIME_TRANSCRIBE_MODEL: &str = "realtime-transcribe"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ReservedLogicalModelName(String); + +impl ReservedLogicalModelName { + pub(crate) fn into_name(self) -> String { + self.0 + } +} + +/// One active physical or logical speech model advertised by the service. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SpeechModelInfo { + name: String, +} + +impl SpeechModelInfo { + pub(crate) fn new(name: String) -> Self { + Self { name } + } + + /// Returns the caller-facing speech model name. + #[must_use] + pub fn name(&self) -> &str { + &self.name + } +} + +#[derive(Debug, Clone)] +pub(crate) struct ModelNames { + interim: String, + final_model: Option, +} + +impl ModelNames { + pub(crate) fn new( + interim: String, + final_model: Option, + ) -> Result { + if interim == REALTIME_TRANSCRIBE_MODEL + || final_model.as_deref() == Some(REALTIME_TRANSCRIBE_MODEL) + { + return Err(ReservedLogicalModelName( + REALTIME_TRANSCRIBE_MODEL.to_owned(), + )); + } + Ok(Self { + interim, + final_model, + }) + } + + pub(crate) fn scripted(has_final: bool) -> Self { + Self { + interim: "scripted-interim".to_owned(), + final_model: has_final.then(|| "scripted-final".to_owned()), + } + } + + pub(crate) fn select(&self, name: &str) -> Option { + if self.interim == name { + Some(DecodeMode::Interim) + } else if self.final_model.as_deref() == Some(name) { + Some(DecodeMode::Final) + } else { + None + } + } + + pub(crate) fn infos(&self) -> Vec { + let mut models = Vec::with_capacity(if self.final_model.is_some() { 3 } else { 1 }); + models.push(SpeechModelInfo::new(self.interim.clone())); + if let Some(final_model) = &self.final_model { + models.push(SpeechModelInfo::new(final_model.clone())); + models.push(SpeechModelInfo::new(REALTIME_TRANSCRIBE_MODEL.to_owned())); + } + models + } +} + +#[cfg(test)] +mod tests { + use super::ModelNames; + + #[test] + fn logical_name_is_reserved_from_single_physical_role() { + assert!(ModelNames::new("realtime-transcribe".to_owned(), None).is_err()); + } + + #[test] + fn logical_name_is_reserved_from_paired_physical_roles() { + assert!( + ModelNames::new( + "physical-interim".to_owned(), + Some("realtime-transcribe".to_owned()) + ) + .is_err() + ); + } +} diff --git a/crates/gateway-stt/src/realtime/input.rs b/crates/gateway-stt/src/realtime/input.rs new file mode 100644 index 00000000..911d702e --- /dev/null +++ b/crates/gateway-stt/src/realtime/input.rs @@ -0,0 +1,201 @@ +use crate::audio::{AudioBuffer, AudioError}; +use crate::generation::GenerationLease; +use crate::take::Take; +const INPUT_FORMAT: &str = "audio/pcm"; +const INPUT_RATE: u32 = 24_000; +const INPUT_MODEL: &str = "realtime-transcribe"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct InputSnapshot { + format: &'static str, + rate: u32, + model: &'static str, + prompt: String, + include_hypothesis: bool, +} + +impl InputSnapshot { + pub(crate) fn new(prompt: String, include_hypothesis: bool) -> Self { + Self { + format: INPUT_FORMAT, + rate: INPUT_RATE, + model: INPUT_MODEL, + prompt, + include_hypothesis, + } + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) fn prompt(&self) -> &str { + &self.prompt + } + + #[cfg(test)] + pub(crate) const fn format(&self) -> &str { + self.format + } + + #[cfg(test)] + pub(crate) const fn rate(&self) -> u32 { + self.rate + } + + #[cfg(test)] + pub(crate) const fn model(&self) -> &str { + self.model + } + + pub(crate) const fn include_hypothesis(&self) -> bool { + self.include_hypothesis + } +} + +#[derive(Debug)] +pub(crate) struct UncommittedInput { + item_id: String, + snapshot: InputSnapshot, + audio: AudioBuffer, + take: Take, +} + +#[derive(Debug)] +pub(crate) struct SealedInput { + pub(crate) item_id: String, + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) snapshot: InputSnapshot, + pub(crate) take: Take, + pub(crate) duration_seconds: f64, +} +impl UncommittedInput { + #[cfg(test)] + pub(crate) fn new( + item_id: String, + snapshot: InputSnapshot, + engine: Option, + ) -> Self { + Self::from_audio(item_id, snapshot, engine, AudioBuffer::default()) + } + + pub(crate) fn first_append( + item_id: String, + snapshot: InputSnapshot, + engine: Option, + payload: &str, + ) -> Result { + let mut audio = AudioBuffer::default(); + audio.append_base64(payload)?; + Ok(Self::from_audio(item_id, snapshot, engine, audio)) + } + + fn from_audio( + item_id: String, + snapshot: InputSnapshot, + engine: Option, + mut audio: AudioBuffer, + ) -> Self { + let guidance = if snapshot.prompt.is_empty() { + Vec::new() + } else { + vec![snapshot.prompt.clone()] + }; + let take = Take::new(guidance, engine); + take.append(&audio.take_resampled()); + let mut input = Self { + item_id, + snapshot, + audio, + take, + }; + input.submit_resampled(); + input + } + + pub(crate) fn append_base64(&mut self, payload: &str) -> Result<(), AudioError> { + self.audio.append_base64(payload)?; + self.submit_resampled(); + Ok(()) + } + + pub(crate) fn item_id(&self) -> &str { + &self.item_id + } + + pub(crate) const fn snapshot(&self) -> &InputSnapshot { + &self.snapshot + } + + pub(crate) const fn take(&self) -> &Take { + &self.take + } + + #[cfg(test)] + pub(crate) fn buffered_duration_seconds(&self) -> f64 { + self.audio.buffered_duration_seconds() + } + + pub(crate) fn pending_failure(&self) -> Option { + self.take.pending_failure() + } + + pub(crate) fn record_pending_failure(&mut self, failure: String) { + self.take.record_failure(failure); + } + + pub(crate) fn validate_commit(&self) -> Result<(), AudioError> { + self.audio.validate_commit() + } + + pub(crate) fn seal(mut self) -> SealedInput { + let committed = self.audio.commit_validated(); + self.take.append(committed.samples()); + SealedInput { + item_id: self.item_id, + #[cfg(any(test, feature = "test-fixtures"))] + snapshot: self.snapshot, + take: self.take, + duration_seconds: committed.duration_seconds(), + } + } + + fn submit_resampled(&mut self) { + self.take.append(&self.audio.take_resampled()); + self.take.submit_closed_segments(); + } +} + +#[cfg(test)] +mod tests { + use base64::Engine as _; + + use super::{InputSnapshot, UncommittedInput}; + + fn encoded(samples: &[i16]) -> String { + let bytes = samples + .iter() + .flat_map(|sample| sample.to_le_bytes()) + .collect::>(); + base64::engine::general_purpose::STANDARD.encode(bytes) + } + + fn snapshot(prompt: &str) -> InputSnapshot { + InputSnapshot::new(prompt.to_owned(), true) + } + + #[test] + fn miri_input_owns_snapshot_audio_resampler_and_take_state() { + let mut input = UncommittedInput::new("item_one".to_owned(), snapshot("first"), None); + input + .append_base64(&encoded(&vec![512; 2_400])) + .expect("audio appends"); + + assert_eq!(input.snapshot().prompt(), "first"); + assert_eq!(input.snapshot().format(), "audio/pcm"); + assert_eq!(input.snapshot().rate(), 24_000); + assert_eq!(input.snapshot().model(), "realtime-transcribe"); + assert!(input.snapshot().include_hypothesis()); + assert_eq!(input.item_id(), "item_one"); + assert!((input.buffered_duration_seconds() - 0.1).abs() < f64::EPSILON); + assert_eq!(input.take().guidance(), ["first"]); + assert!(!input.take().uncommitted_snapshot(usize::MAX).is_empty()); + } +} diff --git a/crates/gateway-stt/src/realtime/item.rs b/crates/gateway-stt/src/realtime/item.rs new file mode 100644 index 00000000..1be171f5 --- /dev/null +++ b/crates/gateway-stt/src/realtime/item.rs @@ -0,0 +1,181 @@ +use std::sync::Arc; + +use tokio::task::JoinHandle; + +#[cfg(any(test, feature = "test-fixtures"))] +use super::input::InputSnapshot; +use super::input::SealedInput; +use super::result_mailbox::{ItemFailure, ItemResult}; +use crate::take::Take; + +type FinalizationTask = JoinHandle>; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct CommitReceipt { + item_id: String, + previous_item_id: Option, +} + +impl CommitReceipt { + pub(crate) fn new(item_id: String, previous_item_id: Option) -> Self { + Self { + item_id, + previous_item_id, + } + } + + pub(crate) fn item_id(&self) -> &str { + &self.item_id + } + + pub(crate) fn previous_item_id(&self) -> Option<&str> { + self.previous_item_id.as_deref() + } +} + +#[derive(Debug)] +pub(crate) struct CommittedItem { + id: String, + previous_item_id: Option, + #[cfg(any(test, feature = "test-fixtures"))] + snapshot: InputSnapshot, + #[cfg_attr( + not(any(test, feature = "test-fixtures")), + allow(dead_code, reason = "retains take ownership until item retirement") + )] + take: Arc, + duration_seconds: f64, + finalization: Option, + terminal: bool, +} + +impl CommittedItem { + pub(crate) fn from_sealed( + sealed: SealedInput, + previous_item_id: Option, + ) -> (Self, Option) { + let pending_failure = sealed.take.pending_failure(); + let take = Arc::new(sealed.take); + let finalization = if pending_failure.is_none() { + take.finalization().map(tokio::spawn) + } else { + None + }; + ( + Self { + id: sealed.item_id, + previous_item_id, + #[cfg(any(test, feature = "test-fixtures"))] + snapshot: sealed.snapshot, + take, + duration_seconds: sealed.duration_seconds, + finalization, + terminal: false, + }, + pending_failure, + ) + } + + pub(crate) fn receipt(&self) -> CommitReceipt { + CommitReceipt::new(self.id.clone(), self.previous_item_id.clone()) + } + + pub(crate) fn id(&self) -> &str { + &self.id + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) const fn snapshot(&self) -> &InputSnapshot { + &self.snapshot + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) fn take(&self) -> &Take { + &self.take + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) const fn is_finalizing(&self) -> bool { + self.finalization.is_some() + } + + pub(crate) fn finalization_finished(&self) -> bool { + self.finalization + .as_ref() + .is_some_and(tokio::task::JoinHandle::is_finished) + } + + pub(crate) const fn is_terminal(&self) -> bool { + self.terminal + } + + pub(crate) async fn finish_finalization(&mut self) -> Result { + let Some(task) = self.finalization.as_mut() else { + return Err("the committed item has no active finalization".to_owned()); + }; + let outcome = task + .await + .map_err(|error| format!("committed item finalization task failed: {error}"))?; + self.finalization = None; + match outcome { + Ok(transcript) => self + .completed(transcript) + .ok_or_else(|| "the committed item already reached a terminal outcome".to_owned()), + Err(message) => self + .failed(ItemFailure::TranscriptionFailed(message)) + .ok_or_else(|| "the committed item already reached a terminal outcome".to_owned()), + } + } + + pub(crate) fn take_finalization(&mut self) -> Option { + self.finalization.take() + } + + #[cfg(feature = "test-fixtures")] + pub(crate) fn replace_finalization(&mut self, task: FinalizationTask) { + if let Some(previous) = self.finalization.replace(task) { + previous.abort(); + } + } + + pub(crate) fn completed(&mut self, transcript: String) -> Option { + if std::mem::replace(&mut self.terminal, true) { + return None; + } + Some(ItemResult::Completed { + item_id: self.id.clone(), + transcript, + seconds: self.duration_seconds, + }) + } + + pub(crate) fn failed(&mut self, failure: ItemFailure) -> Option { + if std::mem::replace(&mut self.terminal, true) { + return None; + } + Some(ItemResult::Failed { + item_id: self.id.clone(), + failure, + }) + } +} + +impl Drop for CommittedItem { + fn drop(&mut self) { + if let Some(task) = &self.finalization { + task.abort(); + } + } +} + +#[cfg(test)] +mod tests { + use super::CommitReceipt; + + #[test] + fn miri_commit_receipt_preserves_provisional_id_and_lineage() { + let receipt = CommitReceipt::new("item_two".to_owned(), Some("item_one".to_owned())); + assert_eq!(receipt.item_id(), "item_two"); + assert_eq!(receipt.previous_item_id(), Some("item_one")); + } +} diff --git a/crates/gateway-stt/src/realtime/mod.rs b/crates/gateway-stt/src/realtime/mod.rs new file mode 100644 index 00000000..0a1cfaa2 --- /dev/null +++ b/crates/gateway-stt/src/realtime/mod.rs @@ -0,0 +1,17 @@ +mod input; +mod item; +mod query; +mod registry; +mod result_mailbox; +mod route; +mod session; +mod wire; + +pub(crate) use item::CommitReceipt; +pub(crate) use registry::SessionRegistry; +#[cfg(feature = "test-fixtures")] +pub(crate) use result_mailbox::ItemResult; +#[cfg(feature = "test-fixtures")] +pub(crate) use route::ForcedPrecommitFailure; +pub(crate) use route::{RoutePolicy, routes}; +pub(crate) use session::Session; diff --git a/crates/gateway-stt/src/realtime/query.rs b/crates/gateway-stt/src/realtime/query.rs new file mode 100644 index 00000000..0f04f112 --- /dev/null +++ b/crates/gateway-stt/src/realtime/query.rs @@ -0,0 +1,70 @@ +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(super) enum QueryError { + MissingIntent, + DuplicateParameter, + UnknownParameter, + UnsupportedIntent, + MalformedParameter, +} + +pub(super) fn validate(query: Option<&str>) -> Result<(), QueryError> { + let query = query + .filter(|query| !query.is_empty()) + .ok_or(QueryError::MissingIntent)?; + let mut intent = None; + for parameter in query.split('&') { + let mut parts = parameter.split('='); + let name = parts.next().unwrap_or_default(); + let value = parts.next().ok_or(QueryError::MalformedParameter)?; + if name.is_empty() || value.is_empty() || parts.next().is_some() { + return Err(QueryError::MalformedParameter); + } + if name != "intent" { + return Err(QueryError::UnknownParameter); + } + if intent.replace(value).is_some() { + return Err(QueryError::DuplicateParameter); + } + } + match intent { + None => Err(QueryError::MissingIntent), + Some("transcription") => Ok(()), + Some(_) => Err(QueryError::UnsupportedIntent), + } +} + +#[cfg(test)] +mod tests { + use super::{QueryError, validate}; + + #[test] + fn exact_transcription_intent_is_the_only_accepted_query() { + assert_eq!(validate(Some("intent=transcription")), Ok(())); + assert_eq!(validate(None), Err(QueryError::MissingIntent)); + assert_eq!(validate(Some("")), Err(QueryError::MissingIntent)); + assert_eq!( + validate(Some("intent=transcription&intent=transcription")), + Err(QueryError::DuplicateParameter) + ); + assert_eq!( + validate(Some("intent=transcription&intent=realtime")), + Err(QueryError::DuplicateParameter) + ); + assert_eq!( + validate(Some("intent=transcription&extra=1")), + Err(QueryError::UnknownParameter) + ); + assert_eq!( + validate(Some("intent=realtime")), + Err(QueryError::UnsupportedIntent) + ); + assert_eq!( + validate(Some("intent=transcription=extra")), + Err(QueryError::MalformedParameter) + ); + assert_eq!( + validate(Some("intent=%74ranscription")), + Err(QueryError::UnsupportedIntent) + ); + } +} diff --git a/crates/gateway-stt/src/realtime/registry.rs b/crates/gateway-stt/src/realtime/registry.rs new file mode 100644 index 00000000..38f93413 --- /dev/null +++ b/crates/gateway-stt/src/realtime/registry.rs @@ -0,0 +1,238 @@ +#[cfg(feature = "test-fixtures")] +use std::future::Future; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; + +use tokio::sync::Notify; +use tokio::task::JoinHandle; + +pub(crate) const MAX_ACTIVE_REALTIME_SESSIONS: usize = 8; + +#[derive(Debug, Clone, Copy, Eq, PartialEq, thiserror::Error)] +pub(crate) enum RegisterError { + #[error("the realtime transcription session limit is reached")] + AtCapacity, +} + +#[derive(Default)] +struct RegistryState { + active: usize, + retired_task_failures: usize, +} + +#[derive(Debug, Default)] +struct CleanupSignal { + generation: AtomicUsize, + notified: Notify, +} + +impl CleanupSignal { + fn emit(&self) { + self.generation.fetch_add(1, Ordering::Release); + self.notified.notify_waiters(); + } + + #[cfg(feature = "test-fixtures")] + fn event_count(&self) -> usize { + self.generation.load(Ordering::Acquire) + } + + #[cfg(feature = "test-fixtures")] + fn notified(&self) -> impl Future + '_ { + let observed = self.generation.load(Ordering::Acquire); + async move { + loop { + let notified = self.notified.notified(); + if self.generation.load(Ordering::Acquire) != observed { + return; + } + notified.await; + } + } + } +} + +#[derive(Debug, Default)] +struct RegistryShared { + state: Mutex, + cleanup: CleanupSignal, +} + +impl RegistryShared { + fn release_admission(&self) { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + debug_assert!(state.active > 0); + state.active = state.active.saturating_sub(1); + } + + fn record_retired_task_failures(&self, failures: usize) { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + state.retired_task_failures = state.retired_task_failures.saturating_add(failures); + } + + fn retire( + self: Arc, + interim_tasks: Vec>, + finalization_tasks: Vec>, + ) where + T: Send + 'static, + U: Send + 'static, + { + tokio::spawn(async move { + let failures = join_retired_tasks(interim_tasks).await + + join_retired_tasks(finalization_tasks).await; + if failures != 0 { + self.record_retired_task_failures(failures); + } + self.release_admission(); + self.cleanup.emit(); + }); + } +} + +async fn join_retired_tasks(tasks: Vec>) -> usize { + let mut failures = 0usize; + for task in tasks { + if let Err(error) = task.await + && !error.is_cancelled() + { + // Retirement aborts intentionally produce cancellation; only a panic violates cleanup. + failures = failures.saturating_add(1); + } + } + failures +} + +impl std::fmt::Debug for RegistryState { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("RegistryState") + .field("active", &self.active) + .field("retired_task_failures", &self.retired_task_failures) + .finish() + } +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct SessionRegistry { + shared: Arc, +} + +impl SessionRegistry { + pub(crate) fn register(&self) -> Result { + let mut state = self + .shared + .state + .lock() + .unwrap_or_else(PoisonError::into_inner); + if state.active == MAX_ACTIVE_REALTIME_SESSIONS { + return Err(RegisterError::AtCapacity); + } + state.active += 1; + Ok(SessionRegistration { + shared: Some(Arc::clone(&self.shared)), + }) + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) fn active(&self) -> usize { + self.shared + .state + .lock() + .unwrap_or_else(PoisonError::into_inner) + .active + } + + #[cfg(feature = "test-fixtures")] + pub(crate) fn cleanup_event_count(&self) -> usize { + self.shared.cleanup.event_count() + } + + #[cfg(feature = "test-fixtures")] + pub(crate) fn retired_task_failures(&self) -> usize { + self.shared + .state + .lock() + .unwrap_or_else(PoisonError::into_inner) + .retired_task_failures + } + + #[cfg(feature = "test-fixtures")] + pub(crate) fn cleanup_notified(&self) -> impl Future + '_ { + self.shared.cleanup.notified() + } +} + +#[derive(Debug)] +pub(crate) struct SessionRegistration { + shared: Option>, +} + +impl SessionRegistration { + pub(crate) fn retire( + &mut self, + interim_tasks: Vec>, + finalization_tasks: Vec>, + ) where + T: Send + 'static, + U: Send + 'static, + { + for task in &interim_tasks { + task.abort(); + } + for task in &finalization_tasks { + task.abort(); + } + if interim_tasks.is_empty() && finalization_tasks.is_empty() { + return; + } + let Some(shared) = self.shared.take() else { + return; + }; + shared.retire(interim_tasks, finalization_tasks); + } +} + +impl Drop for SessionRegistration { + fn drop(&mut self) { + let Some(shared) = self.shared.take() else { + return; + }; + shared.release_admission(); + } +} + +#[cfg(test)] +mod tests { + use super::{MAX_ACTIVE_REALTIME_SESSIONS, RegisterError, SessionRegistry}; + + #[test] + fn miri_registry_accepts_exact_capacity_and_rejects_capacity_plus_one() { + assert_eq!(MAX_ACTIVE_REALTIME_SESSIONS, 8); + let registry = SessionRegistry::default(); + let registrations = (0..MAX_ACTIVE_REALTIME_SESSIONS) + .map(|_| registry.register().expect("capacity is admitted")) + .collect::>(); + + assert_eq!(registry.active(), MAX_ACTIVE_REALTIME_SESSIONS); + assert!(matches!( + registry.register(), + Err(RegisterError::AtCapacity) + )); + drop(registrations); + assert_eq!(registry.active(), 0); + } + + #[test] + fn dropped_registration_immediately_reopens_admission() { + let registry = SessionRegistry::default(); + let mut registrations = (0..MAX_ACTIVE_REALTIME_SESSIONS) + .map(|_| registry.register().expect("capacity is admitted")) + .collect::>(); + drop(registrations.pop()); + + let replacement = registry.register().expect("released slot is reused"); + assert_eq!(registry.active(), MAX_ACTIVE_REALTIME_SESSIONS); + drop(replacement); + } +} diff --git a/crates/gateway-stt/src/realtime/result_mailbox.rs b/crates/gateway-stt/src/realtime/result_mailbox.rs new file mode 100644 index 00000000..1e9b7273 --- /dev/null +++ b/crates/gateway-stt/src/realtime/result_mailbox.rs @@ -0,0 +1,241 @@ +use std::collections::{HashMap, VecDeque}; + +pub(crate) const SESSION_RESULT_CAPACITY: usize = 16; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum ItemFailure { + FinalSegmentOverload(String), + PrecommitTranscriptionFailed(String), + TranscriptionFailed(String), +} + +impl ItemFailure { + pub(crate) fn from_precommit(message: &str) -> Self { + if message == "final segment capacity is reached" { + Self::FinalSegmentOverload(message.to_owned()) + } else { + Self::PrecommitTranscriptionFailed(message.to_owned()) + } + } + + #[cfg(feature = "test-fixtures")] + pub(crate) fn diagnostic(&self) -> &str { + match self { + Self::FinalSegmentOverload(message) + | Self::PrecommitTranscriptionFailed(message) + | Self::TranscriptionFailed(message) => message, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum ItemResult { + #[cfg(any(test, feature = "test-fixtures"))] + Delta { item_id: String, transcript: String }, + #[cfg(any(test, feature = "test-fixtures"))] + Hypothesis { + item_id: String, + revision: u64, + transcript: String, + }, + Completed { + item_id: String, + transcript: String, + seconds: f64, + }, + Failed { + item_id: String, + failure: ItemFailure, + }, +} + +impl ItemResult { + pub(crate) fn item_id(&self) -> &str { + match self { + #[cfg(any(test, feature = "test-fixtures"))] + Self::Delta { item_id, .. } | Self::Hypothesis { item_id, .. } => item_id, + Self::Completed { item_id, .. } | Self::Failed { item_id, .. } => item_id, + } + } + + pub(crate) const fn is_terminal(&self) -> bool { + matches!(self, Self::Completed { .. } | Self::Failed { .. }) + } +} + +#[derive(Debug, Default)] +struct ItemSlots { + #[cfg(any(test, feature = "test-fixtures"))] + hypothesis: Option, + terminal: Option, +} + +#[derive(Debug, Eq, PartialEq, thiserror::Error)] +pub(crate) enum MailboxError { + #[cfg(any(test, feature = "test-fixtures"))] + #[error("the realtime session result capacity is reached")] + ResultAtCapacity, + #[error("the committed item already reached a terminal outcome")] + TerminalAlreadySet, + #[error("the committed item is not active")] + UnknownItem, +} + +#[derive(Debug, Default)] +pub(crate) struct ResultMailbox { + #[cfg(any(test, feature = "test-fixtures"))] + results: VecDeque, + slots: HashMap, + #[cfg(any(test, feature = "test-fixtures"))] + hypothesis_order: VecDeque, + terminal_order: VecDeque, +} + +impl ResultMailbox { + pub(crate) fn reserve_item(&mut self, item_id: &str) { + let replaced = self.slots.insert(item_id.to_owned(), ItemSlots::default()); + debug_assert!(replaced.is_none(), "opaque item IDs must be unique"); + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) fn push_delta( + &mut self, + item_id: &str, + transcript: String, + ) -> Result<(), MailboxError> { + let slots = self.slots.get(item_id).ok_or(MailboxError::UnknownItem)?; + if slots.terminal.is_some() { + return Err(MailboxError::TerminalAlreadySet); + } + if self.results.len() == SESSION_RESULT_CAPACITY { + return Err(MailboxError::ResultAtCapacity); + } + self.results.push_back(ItemResult::Delta { + item_id: item_id.to_owned(), + transcript, + }); + Ok(()) + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) fn replace_hypothesis( + &mut self, + item_id: &str, + revision: u64, + transcript: String, + ) -> Result<(), MailboxError> { + let slots = self + .slots + .get_mut(item_id) + .ok_or(MailboxError::UnknownItem)?; + if slots.terminal.is_some() { + return Err(MailboxError::TerminalAlreadySet); + } + if slots.hypothesis.is_none() { + self.hypothesis_order.push_back(item_id.to_owned()); + } + slots.hypothesis = Some(ItemResult::Hypothesis { + item_id: item_id.to_owned(), + revision, + transcript, + }); + Ok(()) + } + + pub(crate) fn set_terminal( + &mut self, + item_id: &str, + result: ItemResult, + ) -> Result<(), MailboxError> { + debug_assert!(result.is_terminal()); + debug_assert_eq!(result.item_id(), item_id); + let slots = self + .slots + .get_mut(item_id) + .ok_or(MailboxError::UnknownItem)?; + if slots.terminal.is_some() { + return Err(MailboxError::TerminalAlreadySet); + } + slots.terminal = Some(result); + self.terminal_order.push_back(item_id.to_owned()); + Ok(()) + } + + pub(crate) fn drain(&mut self) -> Vec { + #[cfg(any(test, feature = "test-fixtures"))] + let mut drained = self.results.drain(..).collect::>(); + #[cfg(not(any(test, feature = "test-fixtures")))] + let mut drained = Vec::new(); + #[cfg(any(test, feature = "test-fixtures"))] + while let Some(item_id) = self.hypothesis_order.pop_front() { + if let Some(result) = self + .slots + .get_mut(&item_id) + .and_then(|slots| slots.hypothesis.take()) + { + drained.push(result); + } + } + while let Some(item_id) = self.terminal_order.pop_front() { + if let Some(result) = self + .slots + .get_mut(&item_id) + .and_then(|slots| slots.terminal.take()) + { + drained.push(result); + } + } + for result in drained.iter().filter(|result| result.is_terminal()) { + self.slots.remove(result.item_id()); + } + drained + } +} + +#[cfg(test)] +mod tests { + use super::{ItemResult, MailboxError, ResultMailbox, SESSION_RESULT_CAPACITY}; + + #[test] + fn miri_result_mailbox_bounds_results_and_reserves_terminal_and_hypothesis_slots() { + let mut mailbox = ResultMailbox::default(); + mailbox.reserve_item("item"); + for index in 0..SESSION_RESULT_CAPACITY { + mailbox + .push_delta("item", index.to_string()) + .expect("ordinary result fits"); + } + assert_eq!( + mailbox.push_delta("item", "overflow".to_owned()), + Err(MailboxError::ResultAtCapacity) + ); + mailbox + .replace_hypothesis("item", 1, "old".to_owned()) + .expect("hypothesis uses its slot"); + mailbox + .replace_hypothesis("item", 2, "new".to_owned()) + .expect("hypothesis is replaceable"); + mailbox + .set_terminal( + "item", + ItemResult::Completed { + item_id: "item".to_owned(), + transcript: "done".to_owned(), + seconds: 0.1, + }, + ) + .expect("terminal uses its reserved slot"); + + let results = mailbox.drain(); + assert_eq!(results.len(), SESSION_RESULT_CAPACITY + 2); + assert!(matches!( + &results[SESSION_RESULT_CAPACITY], + ItemResult::Hypothesis { + revision: 2, + transcript, + .. + } if transcript == "new" + )); + assert!(results.last().is_some_and(ItemResult::is_terminal)); + } +} diff --git a/crates/gateway-stt/src/realtime/route.rs b/crates/gateway-stt/src/realtime/route.rs new file mode 100644 index 00000000..d6784ba6 --- /dev/null +++ b/crates/gateway-stt/src/realtime/route.rs @@ -0,0 +1,438 @@ +use std::time::Duration; + +#[cfg(feature = "test-fixtures")] +use std::sync::Arc; +#[cfg(feature = "test-fixtures")] +use std::sync::atomic::{AtomicUsize, Ordering}; + +use axum::Router; +use axum::extract::State; +use axum::extract::ws::{CloseFrame, Message, WebSocket, WebSocketUpgrade}; +use axum::http::{StatusCode, Uri}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use futures_util::StreamExt as _; + +use super::Session; +use super::query; +use super::registry::{RegisterError, SessionRegistry}; +use super::result_mailbox::MailboxError; +use super::session::SessionError; +use super::wire::{ClientError, ClientEvent, ServerEvent, parse_client_event}; +use crate::audio::AudioError; +use crate::generation::{GenerationLease, GenerationState}; + +const SEND_DEADLINE: Duration = Duration::from_millis(500); +const REPLACEMENT_CLOSE_CODE: u16 = 1012; + +#[derive(Clone, Debug)] +struct RouteState { + generation: GenerationState, + sessions: SessionRegistry, + policy: RoutePolicy, +} + +#[cfg(feature = "test-fixtures")] +#[derive(Clone, Copy, Debug)] +pub(crate) enum ForcedPrecommitFailure { + FinalSegmentOverload, + Transcription, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct RoutePolicy { + #[cfg(feature = "test-fixtures")] + blocked_send: Option>, + #[cfg(feature = "test-fixtures")] + forced_precommit_failure: Option, +} + +#[cfg(feature = "test-fixtures")] +#[derive(Debug)] +struct BlockedSend { + after: usize, + attempted: AtomicUsize, +} + +impl RoutePolicy { + #[cfg(feature = "test-fixtures")] + pub(crate) fn blocking_after(after: usize) -> Self { + Self { + blocked_send: Some(Arc::new(BlockedSend { + after, + attempted: AtomicUsize::new(0), + })), + forced_precommit_failure: None, + } + } + + #[cfg(feature = "test-fixtures")] + pub(crate) fn force_precommit_failure(&mut self, failure: ForcedPrecommitFailure) { + self.forced_precommit_failure = Some(failure); + } + + fn precommit_failure(&self) -> Option<&'static str> { + #[cfg(feature = "test-fixtures")] + if let Some(failure) = self.forced_precommit_failure { + return Some(match failure { + ForcedPrecommitFailure::FinalSegmentOverload => "final segment capacity is reached", + ForcedPrecommitFailure::Transcription => { + "final transcription worker is unavailable" + } + }); + } + None + } + + fn blocks_next(&self) -> bool { + #[cfg(feature = "test-fixtures")] + if let Some(blocked) = &self.blocked_send { + return blocked.attempted.fetch_add(1, Ordering::Relaxed) >= blocked.after; + } + false + } +} + +pub(crate) fn routes( + generation: GenerationState, + sessions: SessionRegistry, + policy: RoutePolicy, +) -> Router { + Router::new() + .route("/v1/realtime", get(upgrade)) + .with_state(RouteState { + generation, + sessions, + policy, + }) +} + +async fn upgrade( + State(state): State, + uri: Uri, + websocket: WebSocketUpgrade, +) -> Response { + if query::validate(uri.query()).is_err() { + return StatusCode::BAD_REQUEST.into_response(); + } + let Some(generation) = state + .generation + .active() + .filter(GenerationLease::has_final_pass) + else { + return StatusCode::SERVICE_UNAVAILABLE.into_response(); + }; + let registration = match state.sessions.register() { + Ok(registration) => registration, + Err(RegisterError::AtCapacity) => return StatusCode::TOO_MANY_REQUESTS.into_response(), + }; + let session = Session::new(registration, Some(generation.clone())); + let policy = state.policy.clone(); + websocket + .on_upgrade(move |socket| run_socket(socket, session, generation, policy)) + .into_response() +} + +async fn run_socket( + mut socket: WebSocket, + mut session: Session, + generation: GenerationLease, + policy: RoutePolicy, +) { + if !send_event(&mut socket, &session.created_event(), &policy).await { + return; + } + let mut completions = tokio::time::interval(Duration::from_millis(10)); + completions.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut interims = tokio::time::interval_at( + tokio::time::Instant::now() + generation.interval(), + generation.interval(), + ); + interims.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + biased; + () = generation.cancelled() => { + if !send_events(&mut socket, &session.replacement_events(), &policy).await { + return; + } + close_for_replacement(&mut socket, &policy).await; + return; + } + _ = completions.tick() => { + if let Err(error) = session.reap_canceled().await { + let error = session_error(&error, None); + if !send_client_error(&mut socket, &session, error, &policy).await { + return; + } + } + if session.interim_finished() { + match session.finish_interim().await { + Ok(Some(event)) => { + if !send_event(&mut socket, &event, &policy).await { + return; + } + } + Ok(None) => {} + Err(error) => { + let error = session_error(&error, None); + if !send_client_error(&mut socket, &session, error, &policy).await { + return; + } + } + } + } + let Ok(events) = session.finish_ready().await else { + return; + }; + if !send_events(&mut socket, &events, &policy).await { + return; + } + } + _ = interims.tick() => { + if session.schedule_interim().is_err() { + return; + } + } + incoming = socket.next() => { + let Some(Ok(message)) = incoming else { + return; + }; + if !handle_message(&mut socket, &mut session, message, &policy).await { + return; + } + } + } + } +} + +async fn handle_message( + socket: &mut WebSocket, + session: &mut Session, + message: Message, + policy: &RoutePolicy, +) -> bool { + match message { + Message::Text(text) => handle_text(socket, session, text.as_str(), policy).await, + Message::Binary(_) => { + send_client_error( + socket, + session, + ClientError::request( + "invalid_frame", + "Realtime client events must be JSON text", + None, + None, + ), + policy, + ) + .await + } + Message::Ping(payload) => send_message(socket, Message::Pong(payload), policy).await, + Message::Pong(_) => true, + Message::Close(_) => false, + } +} + +async fn handle_text( + socket: &mut WebSocket, + session: &mut Session, + text: &str, + policy: &RoutePolicy, +) -> bool { + let event = match parse_client_event(text) { + Ok(event) => event, + Err(error) => return send_client_error(socket, session, error, policy).await, + }; + let client_event_id = event_id(&event); + let result = match event { + ClientEvent::SessionUpdate { .. } => session + .update_text(text) + .map(|()| vec![session.updated_event()]), + ClientEvent::Append { audio, .. } => append_events(session, &audio, policy) + .map_err(|error| session_error(&error, client_event_id)), + ClientEvent::Clear { .. } => session + .clear() + .map(|()| vec![session.cleared_event()]) + .map_err(|error| session_error(&error, client_event_id)), + ClientEvent::Commit { .. } => commit_events(session) + .await + .map_err(|error| session_error(&error, client_event_id)), + }; + match result { + Ok(events) => send_events(socket, &events, policy).await, + Err(error) => send_client_error(socket, session, error, policy).await, + } +} + +fn append_events( + session: &mut Session, + audio: &str, + policy: &RoutePolicy, +) -> Result, SessionError> { + session.ensure_interim_capacity()?; + session.append_base64(audio)?; + if let Some(failure) = policy.precommit_failure() { + session.record_pending_failure(failure.to_owned())?; + } + Ok(Vec::new()) +} + +async fn commit_events(session: &mut Session) -> Result, SessionError> { + let ready_interim = if session.interim_finished() { + session.finish_interim().await? + } else { + None + }; + let receipt = session.commit()?; + let item_id = receipt.item_id().to_owned(); + let mut events = ready_interim.into_iter().collect::>(); + events.extend(session.committed_events(&receipt)); + events.extend(session.take_pending_interim(&item_id)); + events.extend(session.drain_events()); + Ok(events) +} + +fn event_id(event: &ClientEvent) -> Option { + match event { + ClientEvent::SessionUpdate { event_id, .. } + | ClientEvent::Append { event_id, .. } + | ClientEvent::Commit { event_id } + | ClientEvent::Clear { event_id } => event_id.clone(), + } +} +fn session_error(error: &SessionError, client_event_id: Option) -> ClientError { + match error { + SessionError::Audio(AudioError::InvalidBase64) => ClientError::request( + "invalid_base64_audio", + "Audio must be valid Base64", + Some("audio"), + client_event_id, + ), + SessionError::Audio(AudioError::AppendTooLarge { .. }) => ClientError::request( + "audio_append_too_large", + "Decoded audio exceeds the 15 MiB append limit", + Some("audio"), + client_event_id, + ), + SessionError::Audio(AudioError::IncompletePcm16Sample) => ClientError::request( + "invalid_pcm_audio", + "PCM16 audio ends with an incomplete sample", + Some("audio"), + client_event_id, + ), + SessionError::Audio(AudioError::BufferTooLong { .. }) => ClientError::overload( + "too_much_unfinalized_audio", + "Unfinalized audio exceeds 30 seconds", + Some("audio"), + client_event_id, + ), + SessionError::Audio(AudioError::CommitTooShort { .. }) => ClientError::request( + "audio_too_short", + "A commit requires at least 100 ms of audio", + Some("audio"), + client_event_id, + ), + SessionError::CommittedItemsAtCapacity => ClientError::overload( + "too_many_committed_items", + "At most four committed items may finalize concurrently", + None, + client_event_id, + ), + SessionError::InterimAtCapacity => ClientError::overload( + "result_queue_overload", + "The session result queue is full", + None, + client_event_id, + ), + #[cfg(any(test, feature = "test-fixtures"))] + SessionError::Mailbox(MailboxError::ResultAtCapacity) => { + session_error(&SessionError::InterimAtCapacity, client_event_id) + } + SessionError::PendingPrecommitFailure(_) => ClientError::request( + "precommit_transcription_failed", + "Further appends are rejected after accurate precommit failure", + Some("audio"), + client_event_id, + ), + SessionError::CancelJoinAtCapacity => ClientError::overload( + "audio_queue_lag", + "Audio queue lag exceeds two seconds", + Some("audio"), + client_event_id, + ), + SessionError::NoInput => ClientError::request( + "input_audio_buffer_empty", + "The input audio buffer is empty", + Some("audio"), + client_event_id, + ), + SessionError::EpochExhausted + | SessionError::CanceledTaskFailed + | SessionError::GenerationUnavailable + | SessionError::Inference + | SessionError::Finalization(_) + | SessionError::Mailbox(MailboxError::TerminalAlreadySet | MailboxError::UnknownItem) => { + ClientError::server( + "internal_error", + "Transcription failed", + None, + client_event_id, + ) + } + } +} +async fn send_events(socket: &mut WebSocket, events: &[ServerEvent], policy: &RoutePolicy) -> bool { + for event in events { + if !send_event(socket, event, policy).await { + return false; + } + } + true +} +async fn send_client_error( + socket: &mut WebSocket, + session: &Session, + error: ClientError, + policy: &RoutePolicy, +) -> bool { + send_json( + socket, + error.into_server_event(&session.next_event_id()), + policy, + ) + .await +} +async fn send_event(socket: &mut WebSocket, event: &ServerEvent, policy: &RoutePolicy) -> bool { + match serde_json::to_value(event) { + Ok(value) => send_json(socket, value, policy).await, + Err(_) => false, + } +} + +async fn send_json(socket: &mut WebSocket, value: serde_json::Value, policy: &RoutePolicy) -> bool { + send_message(socket, Message::Text(value.to_string().into()), policy).await +} + +async fn send_message(socket: &mut WebSocket, message: Message, policy: &RoutePolicy) -> bool { + tokio::time::timeout(SEND_DEADLINE, async { + if policy.blocks_next() { + std::future::pending::<()>().await; + } + socket.send(message).await + }) + .await + .is_ok_and(|result| result.is_ok()) +} + +async fn close_for_replacement(socket: &mut WebSocket, policy: &RoutePolicy) { + let _sent = send_message( + socket, + Message::Close(Some(CloseFrame { + code: REPLACEMENT_CLOSE_CODE, + reason: "engine_replaced".into(), + })), + policy, + ) + .await; +} diff --git a/crates/gateway-stt/src/realtime/session.rs b/crates/gateway-stt/src/realtime/session.rs new file mode 100644 index 00000000..7a3dbfd5 --- /dev/null +++ b/crates/gateway-stt/src/realtime/session.rs @@ -0,0 +1,486 @@ +use super::input::{InputSnapshot, UncommittedInput}; +use super::item::CommittedItem; +use super::registry::SessionRegistration; +use super::wire::{ClientError, EffectiveSession, IdGenerator, ServerEvent}; +use crate::generation::GenerationLease; +#[cfg(any(test, feature = "test-fixtures"))] +use std::future::Future; +mod items; +mod route; +mod state; + +use state::InterimTaskOutput; +#[cfg(test)] +use state::MAX_COMMITTED_ITEMS_PER_SESSION; +use state::SESSION_CANCEL_JOIN_CAPACITY; +pub(crate) use state::{InterimEpoch, Session, SessionError}; + +impl Session { + pub(crate) fn new(registration: SessionRegistration, engine: Option) -> Self { + let ids = IdGenerator::default(); + let effective = EffectiveSession::new(ids.session()); + Self::empty(registration, engine, ids, effective) + } + + pub(crate) fn update_text(&mut self, text: &str) -> Result<(), ClientError> { + self.effective.apply_update_text(text) + } + + pub(crate) fn append_base64(&mut self, payload: &str) -> Result<(), SessionError> { + if let Some(input) = &mut self.input { + if let Some(failure) = input.pending_failure() { + return Err(SessionError::PendingPrecommitFailure(failure)); + } + return input.append_base64(payload).map_err(SessionError::from); + } + + let snapshot = InputSnapshot::new( + self.effective.prompt().to_owned(), + self.effective.includes_hypothesis(), + ); + let input = UncommittedInput::first_append( + self.ids.item(), + snapshot, + self.engine.clone(), + payload, + )?; + self.input = Some(input); + Ok(()) + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) const fn input(&self) -> Option<&UncommittedInput> { + self.input.as_ref() + } + + pub(crate) fn clear(&mut self) -> Result<(), SessionError> { + if self.input.is_none() { + return Ok(()); + } + if self.interim_task.is_some() && self.canceled_tasks.len() == SESSION_CANCEL_JOIN_CAPACITY + { + return Err(SessionError::CancelJoinAtCapacity); + } + self.invalidate_epoch()?; + if let Some(task) = self.interim_task.take() { + task.abort(); + self.canceled_tasks.push(task); + } + self.input = None; + self.last_interim_window = None; + self.pending_interim.clear(); + self.standard_interim_committed.clear(); + self.hypothesis_revision = 0; + Ok(()) + } + + pub(crate) fn begin_interim(&mut self) -> Result { + if self.input.is_none() { + return Err(SessionError::NoInput); + } + let epoch = InterimEpoch(self.next_epoch); + self.next_epoch = self + .next_epoch + .checked_add(1) + .ok_or(SessionError::EpochExhausted)?; + self.current_epoch = Some(epoch); + Ok(epoch) + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) fn spawn_interim(&mut self, task: F) -> Result + where + F: Future + Send + 'static, + { + if self.interim_task.is_some() && self.canceled_tasks.len() == SESSION_CANCEL_JOIN_CAPACITY + { + return Err(SessionError::CancelJoinAtCapacity); + } + if let Some(previous) = self.interim_task.take() { + previous.abort(); + self.canceled_tasks.push(previous); + } + let epoch = self.begin_interim()?; + self.interim_task = Some(tokio::spawn(async move { + InterimTaskOutput::Fixture(epoch, task.await) + })); + Ok(epoch) + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) fn accept_interim( + &mut self, + epoch: InterimEpoch, + transcript: String, + ) -> Option { + if self.current_epoch != Some(epoch) { + return None; + } + let item_id = self.input.as_ref()?.item_id().to_owned(); + Some(ServerEvent::transcription_delta( + self.ids.event(), + item_id, + transcript, + )) + } + + pub(crate) async fn finish_interim(&mut self) -> Result, SessionError> { + let Some(task) = self.interim_task.as_mut() else { + return Ok(None); + }; + let result = task.await; + self.interim_task = None; + match result.map_err(|_| SessionError::CanceledTaskFailed)? { + #[cfg(any(test, feature = "test-fixtures"))] + InterimTaskOutput::Fixture(epoch, transcript) => { + Ok(self.accept_interim(epoch, transcript)) + } + output @ InterimTaskOutput::Decode { .. } => self.accept_scheduled_interim(output), + } + } + + pub(crate) fn interim_finished(&self) -> bool { + self.interim_task + .as_ref() + .is_some_and(tokio::task::JoinHandle::is_finished) + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) const fn canceled_join_count(&self) -> usize { + self.canceled_tasks.len() + } + + pub(crate) fn record_pending_failure(&mut self, failure: String) -> Result<(), SessionError> { + let input = self.input.as_mut().ok_or(SessionError::NoInput)?; + input.record_pending_failure(failure); + Ok(()) + } + + #[cfg(feature = "test-fixtures")] + pub(crate) fn pending_failure(&self) -> Option { + self.input + .as_ref() + .and_then(UncommittedInput::pending_failure) + } + + #[cfg(feature = "test-fixtures")] + pub(crate) fn pending_final_segments(&self) -> Option { + self.input + .as_ref() + .map(|input| input.take().pending_final_segments()) + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) fn allocated_event_count(&self) -> u64 { + self.ids.event_count() + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) async fn join_canceled(&mut self) -> Result<(), SessionError> { + while let Some(task) = self.canceled_tasks.first_mut() { + let result = task.await; + self.canceled_tasks.remove(0); + self.record_canceled_result(result); + } + self.take_canceled_failure() + } + + pub(crate) async fn reap_canceled(&mut self) -> Result<(), SessionError> { + while self + .canceled_tasks + .first() + .is_some_and(tokio::task::JoinHandle::is_finished) + { + let Some(task) = self.canceled_tasks.first_mut() else { + break; + }; + let result = task.await; + self.canceled_tasks.remove(0); + self.record_canceled_result(result); + } + self.take_canceled_failure() + } + + fn record_canceled_result( + &mut self, + result: Result, + ) { + if result.is_err_and(|error| !error.is_cancelled()) { + self.canceled_task_failed = true; + } + } + + fn take_canceled_failure(&mut self) -> Result<(), SessionError> { + if self.canceled_task_failed { + self.canceled_task_failed = false; + Err(SessionError::CanceledTaskFailed) + } else { + Ok(()) + } + } + + fn invalidate_epoch(&mut self) -> Result<(), SessionError> { + self.next_epoch = self + .next_epoch + .checked_add(1) + .ok_or(SessionError::EpochExhausted)?; + self.current_epoch = None; + Ok(()) + } +} + +impl Drop for Session { + fn drop(&mut self) { + let mut interim_tasks = Vec::with_capacity(self.canceled_tasks.len() + 1); + if let Some(task) = self.interim_task.take() { + interim_tasks.push(task); + } + interim_tasks.append(&mut self.canceled_tasks); + let finalization_tasks = self + .committed + .values_mut() + .filter_map(CommittedItem::take_finalization) + .collect(); + if let Some(mut registration) = self.registration.take() { + registration.retire(interim_tasks, finalization_tasks); + } + } +} + +#[cfg(test)] +mod tests { + use std::future::pending; + + use base64::Engine as _; + + use super::{ + MAX_COMMITTED_ITEMS_PER_SESSION, SESSION_CANCEL_JOIN_CAPACITY, Session, SessionError, + }; + use crate::realtime::registry::SessionRegistry; + + fn encoded(samples: &[i16]) -> String { + let bytes = samples + .iter() + .flat_map(|sample| sample.to_le_bytes()) + .collect::>(); + base64::engine::general_purpose::STANDARD.encode(bytes) + } + + fn update(prompt: &str, include: bool) -> String { + serde_json::json!({ + "type": "session.update", + "session": { + "type": "transcription", + "audio": {"input": {"transcription": {"prompt": prompt}}}, + "include": if include { + vec!["item.input_audio_transcription.hypothesis"] + } else { + Vec::<&str>::new() + } + } + }) + .to_string() + } + + fn session() -> Session { + let registration = SessionRegistry::default() + .register() + .expect("session registers"); + Session::new(registration, None) + } + + #[test] + fn first_successful_append_freezes_configuration_until_clear() { + let mut session = session(); + session + .update_text(&update("first", true)) + .expect("first update applies"); + session + .append_base64(&encoded(&[1, 2, 3])) + .expect("first append succeeds"); + let first_item = session.input().expect("input exists").item_id().to_owned(); + + session + .update_text(&update("second", false)) + .expect("second update applies"); + let input = session.input().expect("input remains"); + assert_eq!(input.item_id(), first_item); + assert_eq!(input.snapshot().prompt(), "first"); + assert!(input.snapshot().include_hypothesis()); + + session.clear().expect("clear succeeds"); + session + .append_base64(&encoded(&[4, 5, 6])) + .expect("next input appends"); + let input = session.input().expect("replacement input exists"); + assert_ne!(input.item_id(), first_item); + assert_eq!(input.snapshot().prompt(), "second"); + assert!(!input.snapshot().include_hypothesis()); + } + + #[test] + fn failed_first_append_does_not_capture_a_snapshot() { + let mut session = session(); + session + .update_text(&update("before", false)) + .expect("update applies"); + assert!(session.append_base64("not base64").is_err()); + assert!(session.input().is_none()); + + session + .update_text(&update("after", true)) + .expect("replacement update applies"); + session + .append_base64(&encoded(&[0, 1])) + .expect("valid append succeeds"); + assert_eq!( + session.input().expect("input exists").snapshot().prompt(), + "after" + ); + } + + #[test] + fn clear_retires_only_input_and_rejects_stale_interim_epochs() { + let mut session = session(); + session + .append_base64(&encoded(&vec![0; 2_400])) + .expect("audio appends"); + let epoch = session.begin_interim().expect("epoch begins"); + assert!( + session + .accept_interim(epoch, "current".to_owned()) + .is_some() + ); + + session.clear().expect("clear succeeds"); + assert!(session.input().is_none()); + assert!(session.accept_interim(epoch, "stale".to_owned()).is_none()); + + session + .append_base64(&encoded(&vec![0; 2_400])) + .expect("replacement audio appends"); + let next = session.begin_interim().expect("new epoch begins"); + assert_ne!(next, epoch); + assert!(session.accept_interim(epoch, "stale".to_owned()).is_none()); + assert!(session.accept_interim(next, "fresh".to_owned()).is_some()); + } + + #[test] + fn miri_interim_epoch_rejects_results_after_clear_and_reuse() { + let mut session = session(); + session + .append_base64(&encoded(&[0, 0])) + .expect("input appends"); + let stale = session.begin_interim().expect("first epoch begins"); + session.clear().expect("input clears"); + session + .append_base64(&encoded(&[0, 0])) + .expect("replacement input appends"); + let current = session.begin_interim().expect("next epoch begins"); + + assert!(session.accept_interim(stale, "stale".to_owned()).is_none()); + assert!( + session + .accept_interim(current, "current".to_owned()) + .is_some() + ); + } + + #[test] + fn miri_commit_reserves_capacity_promotes_ids_and_keeps_lineage() { + let mut session = session(); + let mut previous = None; + let mut committed = Vec::new(); + for _ in 0..MAX_COMMITTED_ITEMS_PER_SESSION { + session + .append_base64(&encoded(&vec![0; 2_400])) + .expect("committable input appends"); + let provisional = session.input().expect("input exists").item_id().to_owned(); + let receipt = session.commit().expect("item commits within capacity"); + assert_eq!(receipt.item_id(), provisional); + assert_eq!(receipt.previous_item_id(), previous.as_deref()); + previous = Some(provisional.clone()); + committed.push(provisional); + } + + session + .append_base64(&encoded(&vec![0; 2_400])) + .expect("retry input appends"); + let retry_id = session + .input() + .expect("retry input exists") + .item_id() + .to_owned(); + assert_eq!( + session.commit(), + Err(SessionError::CommittedItemsAtCapacity) + ); + assert_eq!(session.input().expect("input remains").item_id(), retry_id); + + session + .finalize_completed(&committed[0], "done".to_owned()) + .expect("item finalizes"); + session.drain_results(); + assert_eq!(session.commit().expect("retry commits").item_id(), retry_id); + } + + #[tokio::test] + async fn canceled_task_joins_accept_exact_capacity_and_reject_next() { + assert_eq!(SESSION_CANCEL_JOIN_CAPACITY, 8); + let mut session = session(); + for _ in 0..SESSION_CANCEL_JOIN_CAPACITY { + session + .append_base64(&encoded(&[0, 0])) + .expect("input appends"); + session + .spawn_interim(pending()) + .expect("task starts within join capacity"); + session.clear().expect("task is retained for joining"); + } + assert_eq!(session.canceled_join_count(), SESSION_CANCEL_JOIN_CAPACITY); + + session + .append_base64(&encoded(&[0, 0])) + .expect("capacity-plus-one input appends"); + session + .spawn_interim(pending()) + .expect("capacity-plus-one task starts"); + assert_eq!(session.clear(), Err(SessionError::CancelJoinAtCapacity)); + assert!( + session.input().is_some(), + "recoverable error preserves input" + ); + + session.join_canceled().await.expect("canceled tasks join"); + session.clear().expect("retry succeeds after joins drain"); + } + + #[test] + fn clear_resets_partial_pcm_and_resampler_state() { + let mut reused = session(); + reused + .append_base64(&base64::engine::general_purpose::STANDARD.encode([0x7f])) + .expect("odd byte appends"); + reused.clear().expect("partial input clears"); + reused + .append_base64(&encoded(&vec![123; 2_400])) + .expect("clean input appends"); + + let mut fresh = session(); + fresh + .append_base64(&encoded(&vec![123; 2_400])) + .expect("fresh input appends"); + assert_eq!( + reused + .input() + .expect("reused input") + .take() + .uncommitted_snapshot(usize::MAX), + fresh + .input() + .expect("fresh input") + .take() + .uncommitted_snapshot(usize::MAX) + ); + } +} diff --git a/crates/gateway-stt/src/realtime/session/items.rs b/crates/gateway-stt/src/realtime/session/items.rs new file mode 100644 index 00000000..53feea03 --- /dev/null +++ b/crates/gateway-stt/src/realtime/session/items.rs @@ -0,0 +1,162 @@ +#[cfg(feature = "test-fixtures")] +use std::future::Future; + +use super::state::{ + MAX_COMMITTED_ITEMS_PER_SESSION, SESSION_CANCEL_JOIN_CAPACITY, Session, SessionError, +}; +use crate::realtime::item::{CommitReceipt, CommittedItem}; +use crate::realtime::result_mailbox::{ItemFailure, ItemResult, MailboxError}; + +impl Session { + pub(crate) fn commit(&mut self) -> Result { + let input = self.input.as_ref().ok_or(SessionError::NoInput)?; + input.validate_commit()?; + let item_id = input.item_id().to_owned(); + if self.committed.len() == MAX_COMMITTED_ITEMS_PER_SESSION { + return Err(SessionError::CommittedItemsAtCapacity); + } + if self.interim_task.is_some() && self.canceled_tasks.len() == SESSION_CANCEL_JOIN_CAPACITY + { + return Err(SessionError::CancelJoinAtCapacity); + } + + self.invalidate_epoch()?; + self.results.reserve_item(&item_id); + if let Some(task) = self.interim_task.take() { + task.abort(); + self.canceled_tasks.push(task); + } + self.last_interim_window = None; + let Some(input) = self.input.take() else { + return Err(SessionError::NoInput); + }; + let sealed = input.seal(); + let previous_item_id = self.previous_item_id.clone(); + let (mut item, pending_failure) = CommittedItem::from_sealed(sealed, previous_item_id); + let receipt = item.receipt(); + self.previous_item_id = Some(item_id.clone()); + if let Some(failure) = pending_failure + && let Some(terminal) = item.failed(ItemFailure::from_precommit(&failure)) + { + self.results.set_terminal(&item_id, terminal)?; + } + let replaced = self.committed.insert(item_id, item); + debug_assert!(replaced.is_none(), "opaque item IDs must be unique"); + Ok(receipt) + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) fn committed_count(&self) -> usize { + self.committed.len() + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) fn finalizing_count(&self) -> usize { + self.committed + .values() + .filter(|item| item.is_finalizing()) + .count() + } + + #[cfg(feature = "test-fixtures")] + pub(crate) fn replace_finalization( + &mut self, + item_id: &str, + task: F, + ) -> Result<(), SessionError> + where + F: Future> + Send + 'static, + { + let item = self + .committed + .get_mut(item_id) + .ok_or(MailboxError::UnknownItem)?; + item.replace_finalization(tokio::spawn(task)); + Ok(()) + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) fn committed_prompt_and_guidance(&self, item_id: &str) -> Option<(&str, &[String])> { + self.committed + .get(item_id) + .map(|item| (item.snapshot().prompt(), item.take().guidance())) + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) fn push_delta( + &mut self, + item_id: &str, + transcript: String, + ) -> Result<(), SessionError> { + self.results.push_delta(item_id, transcript)?; + Ok(()) + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) fn replace_hypothesis( + &mut self, + item_id: &str, + revision: u64, + transcript: String, + ) -> Result<(), SessionError> { + self.results + .replace_hypothesis(item_id, revision, transcript)?; + Ok(()) + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) fn finalize_completed( + &mut self, + item_id: &str, + transcript: String, + ) -> Result<(), SessionError> { + let item = self + .committed + .get_mut(item_id) + .ok_or(MailboxError::UnknownItem)?; + let terminal = item + .completed(transcript) + .ok_or(MailboxError::TerminalAlreadySet)?; + self.results.set_terminal(item_id, terminal)?; + Ok(()) + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) fn finalize_failed( + &mut self, + item_id: &str, + message: String, + ) -> Result<(), SessionError> { + let item = self + .committed + .get_mut(item_id) + .ok_or(MailboxError::UnknownItem)?; + let terminal = item + .failed(ItemFailure::TranscriptionFailed(message)) + .ok_or(MailboxError::TerminalAlreadySet)?; + self.results.set_terminal(item_id, terminal)?; + Ok(()) + } + + pub(crate) async fn finish_finalization(&mut self, item_id: &str) -> Result<(), SessionError> { + let terminal = { + let item = self + .committed + .get_mut(item_id) + .ok_or(MailboxError::UnknownItem)?; + item.finish_finalization() + .await + .map_err(SessionError::Finalization)? + }; + self.results.set_terminal(item_id, terminal)?; + Ok(()) + } + + pub(crate) fn drain_results(&mut self) -> Vec { + let results = self.results.drain(); + for result in results.iter().filter(|result| result.is_terminal()) { + self.committed.remove(result.item_id()); + } + results + } +} diff --git a/crates/gateway-stt/src/realtime/session/route.rs b/crates/gateway-stt/src/realtime/session/route.rs new file mode 100644 index 00000000..e51e915c --- /dev/null +++ b/crates/gateway-stt/src/realtime/session/route.rs @@ -0,0 +1,205 @@ +use super::{Session, SessionError}; +use crate::realtime::result_mailbox::{ItemResult, SESSION_RESULT_CAPACITY}; +use crate::realtime::session::state::InterimTaskOutput; +use crate::realtime::wire::ServerEvent; +use gateway_stt_engine::{DecodeMode, DecodeRequest, EnginePolicy}; +impl Session { + pub(crate) fn created_event(&self) -> ServerEvent { + ServerEvent::session_created(self.ids.event(), self.effective.clone()) + } + + pub(crate) fn updated_event(&self) -> ServerEvent { + ServerEvent::session_updated(self.ids.event(), self.effective.clone()) + } + pub(crate) fn cleared_event(&self) -> ServerEvent { + ServerEvent::input_cleared(self.ids.event()) + } + pub(crate) fn next_event_id(&self) -> String { + self.ids.event() + } + + pub(crate) fn ensure_interim_capacity(&self) -> Result<(), SessionError> { + let standard_client = self.input.as_ref().map_or_else( + || !self.effective.includes_hypothesis(), + |input| !input.snapshot().include_hypothesis(), + ); + if standard_client && self.pending_interim.len() == SESSION_RESULT_CAPACITY { + return Err(SessionError::InterimAtCapacity); + } + Ok(()) + } + pub(crate) fn schedule_interim(&mut self) -> Result<(), SessionError> { + if self.interim_task.is_some() { + return Ok(()); + } + let Some(input) = self.input.as_ref() else { + return Ok(()); + }; + let engine = self + .engine + .as_ref() + .ok_or(SessionError::GenerationUnavailable)?; + let window = input.take().interim_window(engine.window_samples()); + if window.samples.len() < EnginePolicy::MIN_WINDOW_SAMPLES + || EnginePolicy::is_silence(&window.samples) + { + return Ok(()); + } + let origin = (window.segment_start, window.start, window.end); + if self.last_interim_window == Some(origin) { + return Ok(()); + } + let engine = engine.clone(); + let item_id = input.item_id().to_owned(); + let guidance = input.take().guidance().to_vec(); + let finalized = input.take().finalized(); + let epoch = self.begin_interim()?; + self.last_interim_window = Some(origin); + self.interim_task = Some(tokio::spawn(async move { + let transcript = engine + .decode(DecodeRequest::new( + DecodeMode::Interim, + window.samples, + guidance, + finalized, + )) + .await + .map_err(|error| error.to_string()); + InterimTaskOutput::Decode { + epoch, + item_id, + segment_start: window.segment_start, + audio_start: window.start, + audio_end: window.end, + transcript, + } + })); + Ok(()) + } + pub(super) fn accept_scheduled_interim( + &mut self, + output: InterimTaskOutput, + ) -> Result, SessionError> { + #[cfg(any(test, feature = "test-fixtures"))] + let InterimTaskOutput::Decode { + epoch, + item_id, + segment_start, + audio_start, + audio_end, + transcript, + } = output + else { + unreachable!("fixture interims are accepted by the fixture path"); + }; + #[cfg(not(any(test, feature = "test-fixtures")))] + let InterimTaskOutput::Decode { + epoch, + item_id, + segment_start, + audio_start, + audio_end, + transcript, + } = output; + if self.current_epoch != Some(epoch) { + return Ok(None); + } + let input = self.input.as_ref().ok_or(SessionError::NoInput)?; + if input.item_id() != item_id { + return Ok(None); + } + let transcript = transcript.map_err(|_| SessionError::Inference)?; + if transcript.is_empty() { + return Ok(None); + } + let include_hypothesis = input.snapshot().include_hypothesis(); + let update = + input + .take() + .next_window_snapshot(&transcript, segment_start, audio_start, audio_end); + if !include_hypothesis { + if let Some(snapshot) = update { + let committed = snapshot.committed(); + if let Some(delta) = committed.strip_prefix(&self.standard_interim_committed) { + if !delta.is_empty() { + self.pending_interim.push(delta.to_owned()); + } + committed.clone_into(&mut self.standard_interim_committed); + } + } + return Ok(None); + } + self.hypothesis_revision = self + .hypothesis_revision + .checked_add(1) + .ok_or(SessionError::EpochExhausted)?; + let Some(snapshot) = update else { + return Ok(None); + }; + Ok(Some(ServerEvent::hypothesis( + self.ids.event(), + input.item_id().to_owned(), + self.hypothesis_revision, + snapshot, + sample_millis(audio_start), + sample_millis(audio_end), + ))) + } + + pub(crate) fn take_pending_interim(&mut self, item_id: &str) -> Vec { + self.hypothesis_revision = 0; + self.standard_interim_committed.clear(); + self.pending_interim + .drain(..) + .map(|transcript| { + ServerEvent::transcription_delta(self.ids.event(), item_id.to_owned(), transcript) + }) + .collect() + } + pub(crate) fn committed_events( + &self, + receipt: &crate::realtime::CommitReceipt, + ) -> [ServerEvent; 2] { + ServerEvent::committed( + self.ids.event(), + self.ids.event(), + receipt.item_id().to_owned(), + receipt.previous_item_id().map(str::to_owned), + ) + } + pub(crate) fn drain_events(&mut self) -> Vec { + self.drain_results() + .into_iter() + .map(|result: ItemResult| ServerEvent::item_result(self.ids.event(), result)) + .collect() + } + pub(crate) async fn finish_ready(&mut self) -> Result, SessionError> { + let ready = self + .committed + .values() + .filter(|item| item.finalization_finished()) + .map(|item| item.id().to_owned()) + .collect::>(); + for item_id in ready { + self.finish_finalization(&item_id).await?; + } + Ok(self.drain_events()) + } + pub(crate) fn replacement_events(&self) -> Vec { + let mut events = self + .committed + .values() + .filter(|item| !item.is_terminal()) + .map(|item| ServerEvent::engine_replaced_item(self.ids.event(), item.id().to_owned())) + .collect::>(); + if self.input.is_some() { + events.push(ServerEvent::engine_replaced(self.ids.event())); + } + events + } +} + +fn sample_millis(samples: usize) -> u64 { + let millis = samples.saturating_mul(1_000) / EnginePolicy::SAMPLE_RATE; + u64::try_from(millis).unwrap_or(u64::MAX) +} diff --git a/crates/gateway-stt/src/realtime/session/state.rs b/crates/gateway-stt/src/realtime/session/state.rs new file mode 100644 index 00000000..47d58d59 --- /dev/null +++ b/crates/gateway-stt/src/realtime/session/state.rs @@ -0,0 +1,113 @@ +use crate::audio::AudioError; +use crate::generation::GenerationLease; +use crate::realtime::input::UncommittedInput; +use crate::realtime::item::CommittedItem; +use crate::realtime::registry::SessionRegistration; +use crate::realtime::result_mailbox::{MailboxError, ResultMailbox}; +use crate::realtime::wire::{EffectiveSession, IdGenerator}; +use std::collections::HashMap; +use tokio::task::JoinHandle; +pub(super) const SESSION_CANCEL_JOIN_CAPACITY: usize = 8; +pub(super) const MAX_COMMITTED_ITEMS_PER_SESSION: usize = 4; +pub(super) type InterimTask = JoinHandle; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct InterimEpoch(pub(super) u64); +#[derive(Debug)] +pub(super) enum InterimTaskOutput { + #[cfg(any(test, feature = "test-fixtures"))] + Fixture(InterimEpoch, String), + Decode { + epoch: InterimEpoch, + item_id: String, + segment_start: usize, + audio_start: usize, + audio_end: usize, + transcript: Result, + }, +} +#[derive(Debug, Eq, PartialEq, thiserror::Error)] +pub(crate) enum SessionError { + #[error(transparent)] + Audio(#[from] AudioError), + #[error("the canceled interim task join capacity is reached")] + CancelJoinAtCapacity, + #[error("the interim epoch space is exhausted")] + EpochExhausted, + #[error("a canceled interim task failed while joining")] + CanceledTaskFailed, + #[error("there is no uncommitted input")] + NoInput, + #[error("the committed realtime item limit is reached")] + CommittedItemsAtCapacity, + #[error("speech generation is unavailable")] + GenerationUnavailable, + #[error("transcription failed")] + Inference, + #[error("the realtime session result capacity is reached")] + InterimAtCapacity, + #[error("{0}")] + PendingPrecommitFailure(String), + #[error("{0}")] + Finalization(String), + #[error(transparent)] + Mailbox(MailboxError), +} + +impl From for SessionError { + fn from(error: MailboxError) -> Self { + #[cfg(any(test, feature = "test-fixtures"))] + if error == MailboxError::ResultAtCapacity { + return Self::InterimAtCapacity; + } + Self::Mailbox(error) + } +} +#[derive(Debug)] +pub(crate) struct Session { + pub(super) registration: Option, + pub(super) engine: Option, + pub(super) ids: IdGenerator, + pub(super) effective: EffectiveSession, + pub(super) input: Option, + pub(super) current_epoch: Option, + pub(super) next_epoch: u64, + pub(super) interim_task: Option, + pub(super) last_interim_window: Option<(usize, usize, usize)>, + pub(super) canceled_tasks: Vec, + pub(super) canceled_task_failed: bool, + pub(super) committed: HashMap, + pub(super) previous_item_id: Option, + pub(super) pending_interim: Vec, + pub(super) standard_interim_committed: String, + pub(super) hypothesis_revision: u64, + pub(super) results: ResultMailbox, +} + +impl Session { + pub(super) fn empty( + registration: SessionRegistration, + engine: Option, + ids: IdGenerator, + effective: EffectiveSession, + ) -> Self { + Self { + registration: Some(registration), + engine, + ids, + effective, + input: None, + current_epoch: None, + next_epoch: 1, + interim_task: None, + last_interim_window: None, + canceled_tasks: Vec::with_capacity(SESSION_CANCEL_JOIN_CAPACITY), + canceled_task_failed: false, + committed: HashMap::with_capacity(MAX_COMMITTED_ITEMS_PER_SESSION), + previous_item_id: None, + pending_interim: Vec::new(), + standard_interim_committed: String::new(), + hypothesis_revision: 0, + results: ResultMailbox::default(), + } + } +} diff --git a/crates/gateway-stt/src/realtime/wire.rs b/crates/gateway-stt/src/realtime/wire.rs new file mode 100644 index 00000000..a79810f2 --- /dev/null +++ b/crates/gateway-stt/src/realtime/wire.rs @@ -0,0 +1,24 @@ +mod client; +mod server; +mod shared; + +#[cfg(test)] +mod tests; + +#[allow( + unused_imports, + reason = "private wire surface is consumed by later realtime steps" +)] +pub(in crate::realtime) use client::parse_client_event; +#[allow( + unused_imports, + reason = "private wire surface is consumed by later realtime steps" +)] +pub(in crate::realtime) use server::{ + ConversationItem, DurationUsage, EffectiveSession, ServerEvent, WireError, +}; +#[allow( + unused_imports, + reason = "private wire surface is consumed by later realtime steps" +)] +pub(in crate::realtime) use shared::{ClientError, ClientEvent, IdGenerator}; diff --git a/crates/gateway-stt/src/realtime/wire/client.rs b/crates/gateway-stt/src/realtime/wire/client.rs new file mode 100644 index 00000000..d6a91428 --- /dev/null +++ b/crates/gateway-stt/src/realtime/wire/client.rs @@ -0,0 +1,363 @@ +use serde_json::{Map, Value}; + +use super::shared::{ + AUDIO_RATE, AUDIO_TYPE, ClientError, ClientEvent, Correlation, HYPOTHESIS_INCLUDE, MODEL, + SESSION_TYPE, SessionPatch, +}; + +pub(in crate::realtime) fn parse_client_event(text: &str) -> Result { + let value: Value = serde_json::from_str(text).map_err(|_| { + ClientError::new( + "invalid_json", + "The client event is not valid JSON", + None, + Correlation::Omitted, + ) + })?; + let object = value.as_object().ok_or_else(|| { + ClientError::new( + "invalid_json", + "The client event is not valid JSON", + None, + Correlation::Omitted, + ) + })?; + let correlation = correlation(object)?; + let event_type = required_string(object, "type", "type", &correlation)?; + match event_type { + "session.update" => parse_update(object, &correlation), + "input_audio_buffer.append" => parse_append(object, &correlation), + "input_audio_buffer.commit" => parse_empty(object, &correlation, true), + "input_audio_buffer.clear" => parse_empty(object, &correlation, false), + unsupported => Err(ClientError::new( + "unsupported_event_type", + format!("Unsupported client event type {unsupported}"), + Some("type"), + correlation, + )), + } +} + +fn correlation(object: &Map) -> Result { + match object.get("event_id") { + None => Ok(Correlation::Omitted), + Some(Value::String(id)) if !id.is_empty() => Ok(Correlation::Client(id.clone())), + Some(_) => Err(ClientError::new( + "invalid_event_id", + "event_id must be a string", + Some("event_id"), + Correlation::Null, + )), + } +} + +fn required_string<'a>( + object: &'a Map, + field: &str, + path: &str, + correlation: &Correlation, +) -> Result<&'a str, ClientError> { + match object.get(field) { + Some(Value::String(value)) => Ok(value), + Some(_) => Err(ClientError::new( + "invalid_field", + format!("{path} must be a string"), + Some(path), + correlation.clone(), + )), + None => Err(ClientError::new( + "missing_required_field", + format!("Missing required field {path}"), + Some(path), + correlation.clone(), + )), + } +} + +fn reject_unknown( + object: &Map, + allowed: &[&str], + prefix: &str, + correlation: &Correlation, +) -> Result<(), ClientError> { + if let Some(field) = object + .keys() + .find(|field| !allowed.contains(&field.as_str())) + { + let path = format!("{prefix}{field}"); + return Err(ClientError::new( + "unknown_field", + format!("Unknown field {path}"), + Some(&path), + correlation.clone(), + )); + } + Ok(()) +} + +fn object_at<'a>( + value: &'a Value, + path: &str, + correlation: &Correlation, +) -> Result<&'a Map, ClientError> { + value.as_object().ok_or_else(|| { + ClientError::new( + "invalid_field", + format!("{path} must be an object"), + Some(path), + correlation.clone(), + ) + }) +} + +fn parse_append( + object: &Map, + correlation: &Correlation, +) -> Result { + reject_unknown(object, &["type", "audio", "event_id"], "", correlation)?; + let audio = required_string(object, "audio", "audio", correlation)?.to_owned(); + Ok(ClientEvent::Append { + event_id: client_id(correlation), + audio, + }) +} + +fn parse_empty( + object: &Map, + correlation: &Correlation, + commit: bool, +) -> Result { + reject_unknown(object, &["type", "event_id"], "", correlation)?; + let event_id = client_id(correlation); + Ok(if commit { + ClientEvent::Commit { event_id } + } else { + ClientEvent::Clear { event_id } + }) +} + +fn client_id(correlation: &Correlation) -> Option { + match correlation { + Correlation::Client(id) => Some(id.clone()), + Correlation::Omitted | Correlation::Null => None, + } +} + +fn parse_update( + object: &Map, + correlation: &Correlation, +) -> Result { + reject_unknown(object, &["type", "session", "event_id"], "", correlation)?; + let session_value = object.get("session").ok_or_else(|| { + ClientError::new( + "missing_required_field", + "Missing required field session", + Some("session"), + correlation.clone(), + ) + })?; + let session = object_at(session_value, "session", correlation)?; + reject_unknown( + session, + &["type", "audio", "include"], + "session.", + correlation, + )?; + let session_type = required_string(session, "type", "session.type", correlation)?; + if session_type != SESSION_TYPE { + return Err(ClientError::new( + "unsupported_session_type", + "Only transcription sessions are supported", + Some("session.type"), + correlation.clone(), + )); + } + let prompt = match session.get("audio") { + Some(audio) => parse_audio(audio, correlation)?, + None => None, + }; + let include_hypothesis = match session.get("include") { + Some(include) => Some(parse_include(include, correlation)?), + None => None, + }; + Ok(ClientEvent::SessionUpdate { + event_id: client_id(correlation), + patch: SessionPatch { + prompt, + include_hypothesis, + }, + }) +} + +fn parse_audio(value: &Value, correlation: &Correlation) -> Result, ClientError> { + let audio = object_at(value, "session.audio", correlation)?; + reject_unknown(audio, &["input"], "session.audio.", correlation)?; + let Some(input) = audio.get("input") else { + return Ok(None); + }; + let input = object_at(input, "session.audio.input", correlation)?; + reject_unknown( + input, + &[ + "format", + "noise_reduction", + "transcription", + "turn_detection", + ], + "session.audio.input.", + correlation, + )?; + if input + .get("noise_reduction") + .is_some_and(|value| !value.is_null()) + { + return Err(ClientError::new( + "unsupported_noise_reduction", + "Only null noise reduction is supported", + Some("session.audio.input.noise_reduction"), + correlation.clone(), + )); + } + if input + .get("turn_detection") + .is_some_and(|value| !value.is_null()) + { + return Err(ClientError::new( + "unsupported_turn_detection", + "Only null turn detection is supported", + Some("session.audio.input.turn_detection"), + correlation.clone(), + )); + } + if let Some(format) = input.get("format") { + parse_format(format, correlation)?; + } + input + .get("transcription") + .map(|transcription| parse_transcription(transcription, correlation)) + .transpose() + .map(Option::flatten) +} + +fn parse_format(value: &Value, correlation: &Correlation) -> Result<(), ClientError> { + let format = object_at(value, "session.audio.input.format", correlation)?; + reject_unknown( + format, + &["type", "rate"], + "session.audio.input.format.", + correlation, + )?; + if let Some(kind) = format.get("type") + && kind.as_str() != Some(AUDIO_TYPE) + { + return Err(ClientError::new( + "unsupported_audio_format", + "Only audio/pcm is supported", + Some("session.audio.input.format.type"), + correlation.clone(), + )); + } + if let Some(rate) = format.get("rate") + && rate.as_u64() != Some(u64::from(AUDIO_RATE)) + { + return Err(ClientError::new( + "unsupported_audio_format", + "Only 24 kHz PCM audio is supported", + Some("session.audio.input.format.rate"), + correlation.clone(), + )); + } + Ok(()) +} + +fn parse_transcription( + value: &Value, + correlation: &Correlation, +) -> Result, ClientError> { + let transcription = object_at(value, "session.audio.input.transcription", correlation)?; + for (field, code, message) in [ + ( + "language", + "unsupported_language", + "A transcription language is not supported", + ), + ( + "logprobs", + "unsupported_logprobs", + "Transcription logprobs are not supported", + ), + ( + "keywords", + "unsupported_keywords", + "Transcription keywords are not supported", + ), + ( + "delay_ms", + "unsupported_delay", + "Transcription delay is not supported", + ), + ] { + if transcription.contains_key(field) { + let path = format!("session.audio.input.transcription.{field}"); + return Err(ClientError::new( + code, + message, + Some(&path), + correlation.clone(), + )); + } + } + reject_unknown( + transcription, + &["model", "prompt"], + "session.audio.input.transcription.", + correlation, + )?; + if let Some(model) = transcription.get("model") + && model.as_str() != Some(MODEL) + { + return Err(ClientError::new( + "unsupported_model", + "Only realtime-transcribe is supported", + Some("session.audio.input.transcription.model"), + correlation.clone(), + )); + } + match transcription.get("prompt") { + None => Ok(None), + Some(Value::String(prompt)) => Ok(Some(prompt.clone())), + Some(_) => Err(ClientError::new( + "invalid_prompt", + "Transcription prompt must be a string", + Some("session.audio.input.transcription.prompt"), + correlation.clone(), + )), + } +} + +fn parse_include(value: &Value, correlation: &Correlation) -> Result { + let values = value.as_array().ok_or_else(|| { + ClientError::new( + "invalid_include", + "session.include must be an array", + Some("session.include"), + correlation.clone(), + ) + })?; + if values.is_empty() { + return Ok(false); + } + if values.len() == 1 && values[0].as_str() == Some(HYPOTHESIS_INCLUDE) { + return Ok(true); + } + let unsupported = values + .iter() + .find_map(Value::as_str) + .unwrap_or(""); + Err(ClientError::new( + "unsupported_include", + format!("Unsupported include value {unsupported}"), + Some("session.include"), + correlation.clone(), + )) +} diff --git a/crates/gateway-stt/src/realtime/wire/server.rs b/crates/gateway-stt/src/realtime/wire/server.rs new file mode 100644 index 00000000..4ee905b3 --- /dev/null +++ b/crates/gateway-stt/src/realtime/wire/server.rs @@ -0,0 +1,400 @@ +use serde::{Deserialize, Serialize}; +#[cfg(test)] +use serde_json::Value; + +use super::client::parse_client_event; +use super::shared::{ + AUDIO_RATE, AUDIO_TYPE, ClientError, ClientEvent, HYPOTHESIS_INCLUDE, MODEL, OptionalNullable, + RequiredNullable, SESSION_OBJECT, SESSION_TYPE, deserialize_required_nullable, +}; + +mod events; + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct EffectiveSession { + id: String, + object: String, + r#type: String, + audio: EffectiveAudio, + include: Vec, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct EffectiveAudio { + input: EffectiveInput, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct EffectiveInput { + format: AudioFormat, + #[serde(deserialize_with = "deserialize_required_nullable")] + noise_reduction: RequiredNullable, + transcription: EffectiveTranscription, + #[serde(deserialize_with = "deserialize_required_nullable")] + turn_detection: RequiredNullable, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct AudioFormat { + r#type: String, + rate: u32, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct EffectiveTranscription { + model: String, + prompt: String, +} + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +enum Never {} + +impl EffectiveSession { + pub(in crate::realtime) fn new(id: String) -> Self { + Self { + id, + object: SESSION_OBJECT.to_owned(), + r#type: SESSION_TYPE.to_owned(), + audio: EffectiveAudio { + input: EffectiveInput { + format: AudioFormat { + r#type: AUDIO_TYPE.to_owned(), + rate: AUDIO_RATE, + }, + noise_reduction: RequiredNullable::Null, + transcription: EffectiveTranscription { + model: MODEL.to_owned(), + prompt: String::new(), + }, + turn_detection: RequiredNullable::Null, + }, + }, + include: Vec::new(), + } + } + + pub(in crate::realtime) fn apply_update_text(&mut self, text: &str) -> Result<(), ClientError> { + let event = parse_client_event(text)?; + if let ClientEvent::SessionUpdate { patch, .. } = event { + let mut candidate = self.clone(); + if let Some(prompt) = patch.prompt { + candidate.audio.input.transcription.prompt = prompt; + } + if let Some(include) = patch.include_hypothesis { + candidate.include = if include { + vec![HYPOTHESIS_INCLUDE.to_owned()] + } else { + Vec::new() + }; + } + *self = candidate; + } + Ok(()) + } + + pub(in crate::realtime) fn prompt(&self) -> &str { + &self.audio.input.transcription.prompt + } + + pub(in crate::realtime) fn includes_hypothesis(&self) -> bool { + !self.include.is_empty() + } + + #[cfg(test)] + fn validate(&self) -> Result<(), String> { + if self.id.is_empty() + || self.object != SESSION_OBJECT + || self.r#type != SESSION_TYPE + || self.audio.input.format.r#type != AUDIO_TYPE + || self.audio.input.format.rate != AUDIO_RATE + || self.audio.input.transcription.model != MODEL + || !self.audio.input.noise_reduction.is_null() + || !self.audio.input.turn_detection.is_null() + || !(self.include.is_empty() + || matches!(self.include.as_slice(), [value] if value == HYPOTHESIS_INCLUDE)) + { + return Err("invalid effective transcription session".to_owned()); + } + Ok(()) + } +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type", deny_unknown_fields)] +pub(crate) enum ServerEvent { + #[serde(rename = "session.created")] + SessionCreated { + event_id: String, + session: EffectiveSession, + }, + #[serde(rename = "session.updated")] + SessionUpdated { + event_id: String, + session: EffectiveSession, + }, + #[serde(rename = "input_audio_buffer.committed")] + InputCommitted { + event_id: String, + item_id: String, + #[serde(deserialize_with = "deserialize_required_nullable")] + previous_item_id: RequiredNullable, + }, + #[serde(rename = "input_audio_buffer.cleared")] + InputCleared { event_id: String }, + #[serde(rename = "conversation.item.created")] + ItemCreated { + event_id: String, + #[serde(deserialize_with = "deserialize_required_nullable")] + previous_item_id: RequiredNullable, + item: ConversationItem, + }, + #[serde(rename = "conversation.item.input_audio_transcription.delta")] + TranscriptionDelta { + event_id: String, + item_id: String, + content_index: u8, + delta: String, + }, + #[serde(rename = "conversation.item.input_audio_transcription.completed")] + TranscriptionCompleted { + event_id: String, + item_id: String, + content_index: u8, + transcript: String, + usage: DurationUsage, + }, + #[serde(rename = "conversation.item.input_audio_transcription.failed")] + TranscriptionFailed { + event_id: String, + item_id: String, + content_index: u8, + error: WireError, + }, + #[serde(rename = "conversation.item.input_audio_transcription.hypothesis")] + TranscriptionHypothesis { + event_id: String, + item_id: String, + content_index: u8, + revision: u64, + transcript: String, + finalized: String, + agreed: String, + tentative: String, + audio_start_ms: u64, + audio_end_ms: u64, + }, + #[serde(rename = "error")] + Error { event_id: String, error: WireError }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ConversationItem { + id: String, + r#type: String, + status: String, + role: String, + content: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct InputAudioContent { + r#type: String, + #[serde(deserialize_with = "deserialize_required_nullable")] + transcript: RequiredNullable, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct DurationUsage { + r#type: String, + seconds: f64, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct WireError { + r#type: String, + code: String, + message: String, + #[serde(default, skip_serializing_if = "OptionalNullable::is_missing")] + param: OptionalNullable, + #[serde(default, skip_serializing_if = "OptionalNullable::is_missing")] + event_id: OptionalNullable, +} + +impl ServerEvent { + pub(in crate::realtime) fn transcription_delta( + event_id: String, + item_id: String, + delta: String, + ) -> Self { + Self::TranscriptionDelta { + event_id, + item_id, + content_index: 0, + delta, + } + } + + #[cfg(test)] + pub(in crate::realtime) fn from_value(value: Value) -> Result { + let event: Self = serde_json::from_value(value).map_err(|error| error.to_string())?; + event.validate()?; + Ok(event) + } + + #[cfg(test)] + fn validate(&self) -> Result<(), String> { + let (event_id, item_id, content_index) = match self { + Self::SessionCreated { event_id, session } + | Self::SessionUpdated { event_id, session } => { + session.validate()?; + (event_id, None, None) + } + Self::InputCommitted { + event_id, + item_id, + previous_item_id, + } => { + validate_optional_id(previous_item_id.as_ref().map(String::as_str))?; + (event_id, Some(item_id), None) + } + Self::InputCleared { event_id } | Self::Error { event_id, .. } => { + (event_id, None, None) + } + Self::ItemCreated { + event_id, + previous_item_id, + item, + } => { + validate_optional_id(previous_item_id.as_ref().map(String::as_str))?; + item.validate()?; + (event_id, Some(&item.id), None) + } + Self::TranscriptionDelta { + event_id, + item_id, + content_index, + .. + } + | Self::TranscriptionCompleted { + event_id, + item_id, + content_index, + .. + } + | Self::TranscriptionFailed { + event_id, + item_id, + content_index, + .. + } + | Self::TranscriptionHypothesis { + event_id, + item_id, + content_index, + .. + } => (event_id, Some(item_id), Some(content_index)), + }; + validate_id(event_id)?; + if let Some(item_id) = item_id { + validate_id(item_id)?; + } + if content_index.is_some_and(|index| *index != 0) { + return Err("content_index must be zero".to_owned()); + } + match self { + Self::TranscriptionCompleted { usage, .. } => usage.validate(), + Self::TranscriptionFailed { error, .. } => { + error.validate()?; + if error.has_event_id() { + return Err("item failure must not contain a client event ID".to_owned()); + } + Ok(()) + } + Self::Error { error, .. } => error.validate(), + Self::TranscriptionHypothesis { + transcript, + finalized, + agreed, + tentative, + audio_start_ms, + audio_end_ms, + .. + } if transcript != &format!("{finalized}{agreed}{tentative}") + || audio_start_ms > audio_end_ms => + { + Err("invalid hypothesis snapshot".to_owned()) + } + _ => Ok(()), + } + } +} + +impl ConversationItem { + #[cfg(test)] + fn validate(&self) -> Result<(), String> { + validate_id(&self.id)?; + if self.r#type != "message" + || self.status != "completed" + || self.role != "user" + || self.content.len() != 1 + || self.content[0].r#type != "input_audio" + || !self.content[0].transcript.is_null() + { + return Err("invalid conversation item".to_owned()); + } + Ok(()) + } +} + +impl DurationUsage { + #[cfg(test)] + fn validate(&self) -> Result<(), String> { + if self.r#type != "duration" || !self.seconds.is_finite() || self.seconds < 0.0 { + return Err("invalid duration usage".to_owned()); + } + Ok(()) + } +} + +impl WireError { + #[cfg(test)] + fn has_event_id(&self) -> bool { + !self.event_id.is_missing() + } + + #[cfg(test)] + fn validate(&self) -> Result<(), String> { + if self.r#type.is_empty() + || self.code.is_empty() + || self.message.is_empty() + || self.param.invalid_empty() + || self.event_id.invalid_empty() + { + return Err("invalid wire error".to_owned()); + } + Ok(()) + } +} + +#[cfg(test)] +fn validate_id(id: &str) -> Result<(), String> { + if id.is_empty() { + Err("opaque ID must not be empty".to_owned()) + } else { + Ok(()) + } +} + +#[cfg(test)] +fn validate_optional_id(id: Option<&str>) -> Result<(), String> { + id.map_or(Ok(()), validate_id) +} diff --git a/crates/gateway-stt/src/realtime/wire/server/events.rs b/crates/gateway-stt/src/realtime/wire/server/events.rs new file mode 100644 index 00000000..ae01bd66 --- /dev/null +++ b/crates/gateway-stt/src/realtime/wire/server/events.rs @@ -0,0 +1,180 @@ +use super::{ + ConversationItem, DurationUsage, EffectiveSession, InputAudioContent, ServerEvent, WireError, +}; +use crate::realtime::result_mailbox::{ItemFailure, ItemResult}; +use crate::realtime::wire::shared::{OptionalNullable, RequiredNullable}; +use crate::take::InterimSnapshot; +impl ServerEvent { + pub(in crate::realtime) fn session_created( + event_id: String, + session: EffectiveSession, + ) -> Self { + Self::SessionCreated { event_id, session } + } + + pub(in crate::realtime) fn session_updated( + event_id: String, + session: EffectiveSession, + ) -> Self { + Self::SessionUpdated { event_id, session } + } + + pub(in crate::realtime) fn input_cleared(event_id: String) -> Self { + Self::InputCleared { event_id } + } + + pub(in crate::realtime) fn hypothesis( + event_id: String, + item_id: String, + revision: u64, + snapshot: InterimSnapshot, + audio_start_ms: u64, + audio_end_ms: u64, + ) -> Self { + let (transcript, finalized, agreed, tentative) = snapshot.into_parts(); + Self::TranscriptionHypothesis { + event_id, + item_id, + content_index: 0, + revision, + transcript, + finalized, + agreed, + tentative, + audio_start_ms, + audio_end_ms, + } + } + + pub(in crate::realtime) fn committed( + committed_event_id: String, + created_event_id: String, + item_id: String, + previous_item_id: Option, + ) -> [Self; 2] { + let previous = previous_item_id.map_or(RequiredNullable::Null, RequiredNullable::Value); + [ + Self::InputCommitted { + event_id: committed_event_id, + item_id: item_id.clone(), + previous_item_id: previous.clone(), + }, + Self::ItemCreated { + event_id: created_event_id, + previous_item_id: previous, + item: ConversationItem { + id: item_id, + r#type: "message".to_owned(), + status: "completed".to_owned(), + role: "user".to_owned(), + content: vec![InputAudioContent { + r#type: "input_audio".to_owned(), + transcript: RequiredNullable::Null, + }], + }, + }, + ] + } + + pub(in crate::realtime) fn item_result(event_id: String, result: ItemResult) -> Self { + match result { + #[cfg(any(test, feature = "test-fixtures"))] + ItemResult::Delta { + item_id, + transcript, + } => Self::transcription_delta(event_id, item_id, transcript), + #[cfg(any(test, feature = "test-fixtures"))] + ItemResult::Hypothesis { + item_id, + revision, + transcript, + } => Self::TranscriptionHypothesis { + event_id, + item_id, + content_index: 0, + revision, + finalized: String::new(), + agreed: String::new(), + tentative: transcript.clone(), + transcript, + audio_start_ms: 0, + audio_end_ms: 0, + }, + ItemResult::Completed { + item_id, + transcript, + seconds, + } => Self::TranscriptionCompleted { + event_id, + item_id, + content_index: 0, + transcript, + usage: DurationUsage { + r#type: "duration".to_owned(), + seconds, + }, + }, + ItemResult::Failed { item_id, failure } => Self::TranscriptionFailed { + event_id, + item_id, + content_index: 0, + error: item_failure_error(&failure), + }, + } + } + + pub(in crate::realtime) fn engine_replaced_item(event_id: String, item_id: String) -> Self { + Self::TranscriptionFailed { + event_id, + item_id, + content_index: 0, + error: replacement_error(OptionalNullable::Missing), + } + } + + pub(in crate::realtime) fn engine_replaced(event_id: String) -> Self { + Self::Error { + event_id, + error: replacement_error(OptionalNullable::Null), + } + } +} + +fn item_failure_error(failure: &ItemFailure) -> WireError { + let (kind, code, message, param) = match failure { + ItemFailure::FinalSegmentOverload(_) => ( + "overload_error", + "final_segment_overload", + "The authoritative segment could not be admitted", + OptionalNullable::Null, + ), + ItemFailure::PrecommitTranscriptionFailed(_) => ( + "server_error", + "precommit_transcription_failed", + "Accurate precommit transcription failed", + OptionalNullable::Null, + ), + ItemFailure::TranscriptionFailed(_) => ( + "server_error", + "transcription_failed", + "Authoritative transcription failed", + OptionalNullable::Value("audio".to_owned()), + ), + }; + WireError { + r#type: kind.to_owned(), + code: code.to_owned(), + message: message.to_owned(), + param, + event_id: OptionalNullable::Missing, + } +} +fn replacement_error(event_id: OptionalNullable) -> WireError { + WireError { + r#type: "server_error".to_owned(), + code: "engine_replaced".to_owned(), + message: "The speech engine was replaced".to_owned(), + param: OptionalNullable::Null, + event_id, + } +} diff --git a/crates/gateway-stt/src/realtime/wire/shared.rs b/crates/gateway-stt/src/realtime/wire/shared.rs new file mode 100644 index 00000000..0c2adbaf --- /dev/null +++ b/crates/gateway-stt/src/realtime/wire/shared.rs @@ -0,0 +1,258 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +pub(super) const SESSION_OBJECT: &str = "realtime.transcription_session"; +pub(super) const SESSION_TYPE: &str = "transcription"; +pub(super) const AUDIO_TYPE: &str = "audio/pcm"; +pub(super) const AUDIO_RATE: u32 = 24_000; +pub(super) const MODEL: &str = "realtime-transcribe"; +pub(super) const HYPOTHESIS_INCLUDE: &str = "item.input_audio_transcription.hypothesis"; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub(in crate::realtime) enum ClientEvent { + SessionUpdate { + event_id: Option, + patch: SessionPatch, + }, + Append { + event_id: Option, + audio: String, + }, + Commit { + event_id: Option, + }, + Clear { + event_id: Option, + }, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub(in crate::realtime) struct SessionPatch { + pub(super) prompt: Option, + pub(super) include_hypothesis: Option, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub(super) enum Correlation { + Omitted, + Null, + Client(String), +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub(crate) struct ClientError { + kind: &'static str, + code: &'static str, + message: String, + param: Option, + correlation: Correlation, +} + +impl ClientError { + pub(super) fn new( + code: &'static str, + message: impl Into, + param: Option<&str>, + correlation: Correlation, + ) -> Self { + Self { + kind: "invalid_request_error", + code, + message: message.into(), + param: param.map(str::to_owned), + correlation, + } + } + + pub(in crate::realtime) fn into_server_event(self, event_id: &str) -> Value { + let mut error = Map::new(); + error.insert("type".to_owned(), Value::from(self.kind)); + error.insert("code".to_owned(), Value::from(self.code)); + error.insert("message".to_owned(), Value::from(self.message)); + if let Some(param) = self.param { + error.insert("param".to_owned(), Value::from(param)); + } + match self.correlation { + Correlation::Omitted => {} + Correlation::Null => { + error.insert("event_id".to_owned(), Value::Null); + } + Correlation::Client(client_id) => { + error.insert("event_id".to_owned(), Value::from(client_id)); + } + } + serde_json::json!({"event_id": event_id, "type": "error", "error": error}) + } + + pub(in crate::realtime) fn request( + code: &'static str, + message: &'static str, + param: Option<&'static str>, + client_event_id: Option, + ) -> Self { + Self::new( + code, + message, + param, + client_event_id.map_or(Correlation::Omitted, Correlation::Client), + ) + } + + pub(in crate::realtime) fn overload( + code: &'static str, + message: &'static str, + param: Option<&'static str>, + client_event_id: Option, + ) -> Self { + let mut error = Self::request(code, message, param, client_event_id); + error.kind = "overload_error"; + error + } + + pub(in crate::realtime) fn server( + code: &'static str, + message: &'static str, + param: Option<&'static str>, + client_event_id: Option, + ) -> Self { + let mut error = Self::request(code, message, param, client_event_id); + error.kind = "server_error"; + error + } +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub(crate) enum RequiredNullable { + Null, + Value(T), +} + +impl RequiredNullable { + #[cfg(test)] + pub(super) fn as_ref(&self) -> Option<&T> { + match self { + Self::Null => None, + Self::Value(value) => Some(value), + } + } + + #[cfg(test)] + pub(super) fn is_null(&self) -> bool { + matches!(self, Self::Null) + } +} + +impl Serialize for RequiredNullable { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Null => serializer.serialize_none(), + Self::Value(value) => serializer.serialize_some(value), + } + } +} + +impl<'de, T: Deserialize<'de>> Deserialize<'de> for RequiredNullable { + fn deserialize>(deserializer: D) -> Result { + Option::::deserialize(deserializer).map(|value| match value { + Some(value) => Self::Value(value), + None => Self::Null, + }) + } +} + +pub(super) fn deserialize_required_nullable<'de, D, T>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + RequiredNullable::deserialize(deserializer) +} + +#[derive(Debug, Default)] +pub(super) enum OptionalNullable { + #[default] + Missing, + Null, + Value(T), +} + +impl OptionalNullable { + pub(super) fn is_missing(&self) -> bool { + matches!(self, Self::Missing) + } + + #[cfg(test)] + pub(super) fn invalid_empty(&self) -> bool + where + T: AsRef, + { + matches!(self, Self::Value(value) if value.as_ref().is_empty()) + } +} + +impl Serialize for OptionalNullable { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Missing | Self::Null => serializer.serialize_none(), + Self::Value(value) => serializer.serialize_some(value), + } + } +} + +impl<'de, T: Deserialize<'de>> Deserialize<'de> for OptionalNullable { + fn deserialize>(deserializer: D) -> Result { + Option::::deserialize(deserializer).map(|value| match value { + Some(value) => Self::Value(value), + None => Self::Null, + }) + } +} + +static NEXT_GENERATOR: AtomicU64 = AtomicU64::new(1); + +#[derive(Debug)] +pub(in crate::realtime) struct IdGenerator { + namespace: u64, + events: AtomicU64, + sessions: AtomicU64, + items: AtomicU64, +} + +impl Default for IdGenerator { + fn default() -> Self { + Self { + namespace: NEXT_GENERATOR.fetch_add(1, Ordering::Relaxed), + events: AtomicU64::new(1), + sessions: AtomicU64::new(1), + items: AtomicU64::new(1), + } + } +} + +impl IdGenerator { + pub(in crate::realtime) fn event(&self) -> String { + self.next("evt", &self.events) + } + + pub(in crate::realtime) fn session(&self) -> String { + self.next("sess", &self.sessions) + } + + pub(in crate::realtime) fn item(&self) -> String { + self.next("item", &self.items) + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(in crate::realtime) fn event_count(&self) -> u64 { + self.events.load(Ordering::Relaxed).saturating_sub(1) + } + + fn next(&self, kind: &str, counter: &AtomicU64) -> String { + let sequence = counter.fetch_add(1, Ordering::Relaxed); + format!("{kind}_{:016x}_{sequence:016x}", self.namespace) + } +} diff --git a/crates/gateway-stt/src/realtime/wire/tests.rs b/crates/gateway-stt/src/realtime/wire/tests.rs new file mode 100644 index 00000000..f1a96a60 --- /dev/null +++ b/crates/gateway-stt/src/realtime/wire/tests.rs @@ -0,0 +1,278 @@ +use std::collections::HashSet; + +use serde_json::Value; + +use super::{EffectiveSession, IdGenerator, ServerEvent, parse_client_event}; + +const WIRE_INVALID_CASES: &[&str] = &[ + "append_unknown_field", + "clear_unknown_field", + "commit_unknown_field", + "invalid_client_event_id", + "invalid_include_type", + "invalid_prompt_type", + "malformed_json", + "missing_append_audio", + "missing_client_event_type", + "missing_session", + "missing_session_type", + "non_null_noise_reduction", + "non_null_turn_detection", + "session_audio_unknown_field", + "session_input_unknown_field", + "session_transcription_unknown_field", + "session_unknown_field", + "session_update_unknown_field", + "unknown_event_type", + "unknown_include", + "unsupported_delay", + "unsupported_format_rate", + "unsupported_format_type", + "unsupported_keywords", + "unsupported_language", + "unsupported_logprobs", + "unsupported_model", + "wrong_session_type", +]; + +fn fixture(name: &str) -> Value { + let source = match name { + "client-events.json" => { + include_str!("../../../tests/fixtures/realtime/client-events.json") + } + "effective-sessions.json" => { + include_str!("../../../tests/fixtures/realtime/effective-sessions.json") + } + "invalid-sequences.json" => { + include_str!("../../../tests/fixtures/realtime/invalid-sequences.json") + } + "server-events.json" => { + include_str!("../../../tests/fixtures/realtime/server-events.json") + } + other => panic!("unknown fixture {other}"), + }; + serde_json::from_str(source).unwrap_or_else(|error| panic!("{name}: {error}")) +} + +#[test] +fn canonical_client_events_parse_and_updates_are_atomic() { + let clients = fixture("client-events.json"); + for event in clients + .as_object() + .unwrap_or_else(|| panic!("client fixture object")) + .values() + { + let text = serde_json::to_string(event) + .unwrap_or_else(|error| panic!("client fixture serializes: {error}")); + parse_client_event(&text) + .unwrap_or_else(|error| panic!("canonical event rejected: {error:?}")); + } + + let sessions = fixture("effective-sessions.json"); + let mut effective = EffectiveSession::new("sess_canonical".to_owned()); + assert_eq!( + serde_json::to_value(&effective) + .unwrap_or_else(|error| panic!("default session serializes: {error}")), + sessions["default"] + ); + let update = serde_json::to_string(&clients["session_update"]) + .unwrap_or_else(|error| panic!("update fixture serializes: {error}")); + effective + .apply_update_text(&update) + .unwrap_or_else(|error| panic!("canonical update applies: {error:?}")); + assert_eq!( + serde_json::to_value(&effective) + .unwrap_or_else(|error| panic!("updated session serializes: {error}")), + sessions["updated"] + ); + + let invalid = fixture("invalid-sequences.json"); + for case in WIRE_INVALID_CASES { + let before = effective.clone(); + let input = &invalid[*case]["input"]; + let result = if let Some(text) = input["wire_text"].as_str() { + effective.apply_update_text(text) + } else { + let text = serde_json::to_string(&input["message"]) + .unwrap_or_else(|error| panic!("{case} serializes: {error}")); + effective.apply_update_text(&text) + }; + let error = result.unwrap_err(); + let expected = &invalid[*case]["expected_error"]; + let event_id = expected["event_id"] + .as_str() + .unwrap_or_else(|| panic!("{case} has server event ID")); + assert_eq!(error.into_server_event(event_id), *expected, "{case}"); + assert_eq!(effective, before, "{case} must not partially update"); + } +} + +#[test] +fn mixed_valid_and_invalid_session_updates_change_no_effective_state() { + let clients = fixture("client-events.json"); + let update = serde_json::to_string(&clients["session_update"]) + .unwrap_or_else(|error| panic!("update fixture serializes: {error}")); + let mut effective = EffectiveSession::new("sess_atomic".to_owned()); + effective + .apply_update_text(&update) + .unwrap_or_else(|error| panic!("canonical update applies: {error:?}")); + let before = effective.clone(); + + let valid_prompt_invalid_include = serde_json::json!({ + "type": "session.update", + "session": { + "type": "transcription", + "audio": {"input": {"transcription": {"prompt": "must not apply"}}}, + "include": ["unsupported.include"] + } + }); + assert!( + effective + .apply_update_text(&valid_prompt_invalid_include.to_string()) + .is_err() + ); + assert_eq!( + effective, before, + "valid prompt must not apply when include is invalid" + ); + + let valid_include_invalid_prompt = serde_json::json!({ + "type": "session.update", + "session": { + "type": "transcription", + "audio": {"input": {"transcription": {"prompt": 7}}}, + "include": [] + } + }); + assert!( + effective + .apply_update_text(&valid_include_invalid_prompt.to_string()) + .is_err() + ); + assert_eq!( + effective, before, + "valid include must not apply when prompt is invalid" + ); +} + +#[test] +fn canonical_server_events_round_trip_with_exact_shapes() { + let fixture = fixture("server-events.json"); + for (case, value) in fixture + .as_object() + .unwrap_or_else(|| panic!("server fixture object")) + { + let event = ServerEvent::from_value(value.clone()) + .unwrap_or_else(|error| panic!("{case} rejected: {error}")); + assert_eq!( + serde_json::to_value(event) + .unwrap_or_else(|error| panic!("{case} serializes: {error}")), + *value, + "{case}" + ); + } +} + +#[test] +fn omission_of_each_required_nullable_server_field_is_rejected() { + let servers = fixture("server-events.json"); + let cases = [ + ( + "session_created", + &["session", "audio", "input", "noise_reduction"][..], + ), + ( + "session_updated", + &["session", "audio", "input", "turn_detection"][..], + ), + ("input_audio_buffer_committed", &["previous_item_id"][..]), + ("conversation_item_created", &["previous_item_id"][..]), + ( + "conversation_item_created", + &["item", "content", "0", "transcript"][..], + ), + ]; + for (name, path) in cases { + let mut value = servers[name].clone(); + remove_path(&mut value, path); + assert!( + ServerEvent::from_value(value).is_err(), + "{name} must reject omitted {}", + path.join(".") + ); + } +} + +fn remove_path(value: &mut Value, path: &[&str]) { + let (field, parents) = path + .split_last() + .unwrap_or_else(|| panic!("required field path is nonempty")); + let mut parent = value; + for segment in parents { + parent = if let Ok(index) = segment.parse::() { + &mut parent[index] + } else { + &mut parent[*segment] + }; + } + parent + .as_object_mut() + .unwrap_or_else(|| panic!("required field parent is an object")) + .remove(*field) + .unwrap_or_else(|| panic!("required field exists")); +} + +#[test] +fn server_session_event_and_item_ids_use_independent_namespaces() { + let ids = IdGenerator::default(); + let mut events = HashSet::new(); + let mut sessions = HashSet::new(); + let mut items = HashSet::new(); + for _ in 0..64 { + assert!(events.insert(ids.event())); + assert!(sessions.insert(ids.session())); + assert!(items.insert(ids.item())); + } + assert!(events.is_disjoint(&sessions)); + assert!(events.is_disjoint(&items)); + assert!(sessions.is_disjoint(&items)); + assert!(!events.contains("client_event")); + assert!(!sessions.contains("client_event")); + assert!(!items.contains("client_event")); +} + +#[test] +fn invalid_duration_usage_and_hypothesis_shapes_are_rejected() { + let servers = fixture("server-events.json"); + let mut completed = servers["transcription_completed"].clone(); + completed["usage"]["seconds"] = Value::from(-0.01); + assert!(ServerEvent::from_value(completed).is_err()); + + let mut hypothesis = servers["transcription_hypothesis"].clone(); + hypothesis["transcript"] = Value::from("not the three parts"); + assert!(ServerEvent::from_value(hypothesis).is_err()); + let mut reversed_span = servers["transcription_hypothesis"].clone(); + reversed_span["audio_start_ms"] = Value::from(1251_u64); + assert!(ServerEvent::from_value(reversed_span).is_err()); + + let mut failed = servers["transcription_failed"].clone(); + failed["error"]["event_id"] = Value::Null; + assert!(ServerEvent::from_value(failed).is_err()); + + let empty_client_id = r#"{"type":"input_audio_buffer.clear","event_id":""}"#; + let error = parse_client_event(empty_client_id).unwrap_err(); + assert_eq!( + error.into_server_event("evt_empty_client_id"), + serde_json::json!({ + "event_id": "evt_empty_client_id", + "type": "error", + "error": { + "type": "invalid_request_error", + "code": "invalid_event_id", + "message": "event_id must be a string", + "param": "event_id", + "event_id": null + } + }) + ); +} diff --git a/crates/gateway-stt/src/replacement.rs b/crates/gateway-stt/src/replacement.rs new file mode 100644 index 00000000..86d081e0 --- /dev/null +++ b/crates/gateway-stt/src/replacement.rs @@ -0,0 +1,479 @@ +//! Serialized generation replacement and explicit work ownership. +use std::fmt; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex, PoisonError}; +use std::time::Instant; +use tokio::sync::Notify; +#[derive(Debug, Default)] +struct CoordinatorState { + active: Option>, + valid: bool, + shutting_down: bool, +} +#[derive(Debug)] +struct PermitIdentity; +/// One service-wide replacement lane. +#[derive(Debug, Default)] +pub(crate) struct ReplacementCoordinator { + state: Mutex, + changed: Condvar, +} +impl ReplacementCoordinator { + pub(crate) fn acquire(self: &Arc) -> ReplacementPermit { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + while state.active.is_some() || state.shutting_down { + state = self + .changed + .wait(state) + .unwrap_or_else(PoisonError::into_inner); + } + let identity = Arc::new(PermitIdentity); + state.active = Some(Arc::clone(&identity)); + state.valid = true; + ReplacementPermit { + coordinator: Arc::clone(self), + identity: Some(identity), + } + } + + pub(crate) fn begin_shutdown(self: &Arc) -> ShutdownPermit { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + while state.shutting_down { + state = self + .changed + .wait(state) + .unwrap_or_else(PoisonError::into_inner); + } + state.shutting_down = true; + state.valid = false; + ShutdownPermit { + coordinator: Arc::clone(self), + } + } +} +/// Exclusive ownership of one staged replacement transaction. +pub(crate) struct ReplacementPermit { + coordinator: Arc, + identity: Option>, +} +impl fmt::Debug for ReplacementPermit { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ReplacementPermit") + .field("owned", &self.identity.is_some()) + .finish_non_exhaustive() + } +} +impl ReplacementPermit { + pub(crate) fn with_current(&self, operation: impl FnOnce() -> T) -> Option { + let identity = self.identity.as_ref()?; + let state = self + .coordinator + .state + .lock() + .unwrap_or_else(PoisonError::into_inner); + let current = state + .active + .as_ref() + .is_some_and(|active| Arc::ptr_eq(active, identity)) + && state.valid + && !state.shutting_down; + current.then(operation) + } + + pub(crate) fn is_current(&self) -> bool { + self.with_current(|| ()).is_some() + } +} + +impl Drop for ReplacementPermit { + fn drop(&mut self) { + let Some(identity) = self.identity.take() else { + return; + }; + let mut state = self + .coordinator + .state + .lock() + .unwrap_or_else(PoisonError::into_inner); + if state + .active + .as_ref() + .is_some_and(|active| Arc::ptr_eq(active, &identity)) + { + state.active = None; + state.valid = false; + } + drop(state); + self.coordinator.changed.notify_all(); + } +} + +pub(crate) struct ShutdownPermit { + coordinator: Arc, +} + +impl Drop for ShutdownPermit { + fn drop(&mut self) { + let mut state = self + .coordinator + .state + .lock() + .unwrap_or_else(PoisonError::into_inner); + state.shutting_down = false; + drop(state); + self.coordinator.changed.notify_all(); + } +} + +#[derive(Debug)] +struct EpochState { + #[cfg(any(test, feature = "test-fixtures"))] + id: u64, + cancelled: AtomicBool, + changed: Notify, +} + +/// One replaceable cancellation epoch shared by admitted session work. +#[derive(Clone, Debug)] +pub(crate) struct SessionEpoch { + state: Arc, +} + +impl SessionEpoch { + fn new(#[cfg(any(test, feature = "test-fixtures"))] id: u64) -> Self { + Self { + state: Arc::new(EpochState { + #[cfg(any(test, feature = "test-fixtures"))] + id, + cancelled: AtomicBool::new(false), + changed: Notify::new(), + }), + } + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) fn id(&self) -> u64 { + self.state.id + } + + pub(crate) fn is_cancelled(&self) -> bool { + self.state.cancelled.load(Ordering::Acquire) + } + + pub(crate) async fn cancelled(&self) { + let changed = self.state.changed.notified(); + tokio::pin!(changed); + changed.as_mut().enable(); + if self.is_cancelled() { + return; + } + changed.await; + } + + fn cancel(&self) { + self.state.cancelled.store(true, Ordering::Release); + self.state.changed.notify_waiters(); + } +} + +#[derive(Debug)] +enum Admission { + Open, + Closed(Arc), + Shutdown, +} + +#[derive(Debug)] +struct CloseIdentity; + +#[derive(Debug)] +struct AdmissionState { + admission: Admission, + requests: usize, + jobs: usize, + epoch: SessionEpoch, + #[cfg(any(test, feature = "test-fixtures"))] + next_epoch: u64, +} + +/// Mutable admission and ownership state inside one complete generation. +#[derive(Debug)] +pub(crate) struct AdmissionGate { + state: Mutex, + changed: Condvar, +} + +impl Default for AdmissionGate { + fn default() -> Self { + Self { + state: Mutex::new(AdmissionState { + admission: Admission::Open, + requests: 0, + jobs: 0, + epoch: SessionEpoch::new( + #[cfg(any(test, feature = "test-fixtures"))] + 1, + ), + #[cfg(any(test, feature = "test-fixtures"))] + next_epoch: 2, + }), + changed: Condvar::new(), + } + } +} + +#[derive(Debug)] +pub(crate) struct CloseToken { + identity: Arc, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) enum DrainOutcome { + Idle, + Invalidated, + TimedOut, +} + +impl AdmissionGate { + pub(crate) fn admit(self: &Arc) -> Option { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + if !matches!(state.admission, Admission::Open) { + return None; + } + state.requests += 1; + let epoch = state.epoch.clone(); + Some(AdmissionLease { + owner: Arc::new(RequestOwner { + gate: Arc::clone(self), + }), + epoch, + }) + } + + pub(crate) fn close(&self) -> Option { + let (old_epoch, token) = { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + if !matches!(state.admission, Admission::Open) { + return None; + } + let identity = Arc::new(CloseIdentity); + let next_epoch = SessionEpoch::new( + #[cfg(any(test, feature = "test-fixtures"))] + state.next_epoch, + ); + #[cfg(any(test, feature = "test-fixtures"))] + { + state.next_epoch = state.next_epoch.wrapping_add(1).max(1); + } + let old_epoch = std::mem::replace(&mut state.epoch, next_epoch); + state.admission = Admission::Closed(Arc::clone(&identity)); + (old_epoch, CloseToken { identity }) + }; + old_epoch.cancel(); + Some(token) + } + + pub(crate) fn reopen(&self, token: &CloseToken) -> bool { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + let matches = matches!( + &state.admission, + Admission::Closed(identity) if Arc::ptr_eq(identity, &token.identity) + ); + if matches { + state.admission = Admission::Open; + } + drop(state); + if matches { + self.changed.notify_all(); + } + matches + } + + pub(crate) fn shutdown(&self) { + let epoch = { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + state.admission = Admission::Shutdown; + state.epoch.clone() + }; + epoch.cancel(); + self.changed.notify_all(); + } + + pub(crate) fn is_open(&self) -> bool { + matches!( + self.state + .lock() + .unwrap_or_else(PoisonError::into_inner) + .admission, + Admission::Open + ) + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) fn counts(&self) -> (usize, usize) { + let state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + (state.requests, state.jobs) + } + + pub(crate) fn wait_for_idle(&self, token: &CloseToken, deadline: Instant) -> DrainOutcome { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + loop { + if !matches!( + &state.admission, + Admission::Closed(identity) if Arc::ptr_eq(identity, &token.identity) + ) { + return DrainOutcome::Invalidated; + } + if state.requests == 0 && state.jobs == 0 { + return DrainOutcome::Idle; + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return DrainOutcome::TimedOut; + } + let (next, timeout) = self + .changed + .wait_timeout(state, remaining) + .unwrap_or_else(PoisonError::into_inner); + state = next; + if timeout.timed_out() && (state.requests != 0 || state.jobs != 0) { + return DrainOutcome::TimedOut; + } + } + } + + pub(crate) fn wait_until_idle(&self) { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + while state.requests != 0 || state.jobs != 0 { + state = self + .changed + .wait(state) + .unwrap_or_else(PoisonError::into_inner); + } + } + + fn start_job(self: &Arc, epoch: &SessionEpoch) -> Option { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + if !matches!(state.admission, Admission::Open) + || !Arc::ptr_eq(&state.epoch.state, &epoch.state) + || epoch.is_cancelled() + { + return None; + } + state.jobs += 1; + Some(JobLease { + gate: Some(Arc::clone(self)), + }) + } + + fn finish_request(&self) { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + debug_assert!(state.requests > 0); + state.requests = state.requests.saturating_sub(1); + drop(state); + self.changed.notify_all(); + } + + fn finish_job(&self) { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + debug_assert!(state.jobs > 0); + state.jobs = state.jobs.saturating_sub(1); + drop(state); + self.changed.notify_all(); + } +} + +#[derive(Debug)] +struct RequestOwner { + gate: Arc, +} + +impl Drop for RequestOwner { + fn drop(&mut self) { + self.gate.finish_request(); + } +} + +/// Shared ownership for one admitted request or session. +#[derive(Clone, Debug)] +pub(crate) struct AdmissionLease { + owner: Arc, + epoch: SessionEpoch, +} + +impl AdmissionLease { + pub(crate) fn epoch(&self) -> &SessionEpoch { + &self.epoch + } + + pub(crate) fn own_job(&self) -> Option { + self.owner.gate.start_job(&self.epoch) + } +} + +/// Explicit ownership for one admitted worker job. +#[derive(Debug)] +pub(crate) struct JobLease { + gate: Option>, +} + +impl Drop for JobLease { + fn drop(&mut self) { + if let Some(gate) = self.gate.take() { + gate.finish_job(); + } + } +} + +#[cfg(test)] +mod tests { + use super::{AdmissionGate, DrainOutcome, ReplacementCoordinator}; + use std::sync::Arc; + use std::time::{Duration, Instant}; + + #[test] + fn miri_admission_counts_requests_and_jobs_without_reference_counts() { + let gate = Arc::new(AdmissionGate::default()); + let request = gate.admit().expect("open gate admits"); + let job = request.own_job().expect("current epoch admits work"); + assert_eq!(gate.counts(), (1, 1)); + + drop(request); + assert_eq!(gate.counts(), (0, 1)); + drop(job); + assert_eq!(gate.counts(), (0, 0)); + } + + #[test] + fn miri_reopen_installs_a_fresh_epoch_and_rejects_stale_work() { + let gate = Arc::new(AdmissionGate::default()); + let stale = gate.admit().expect("first epoch admits"); + let old_epoch = stale.epoch().id(); + let close = gate.close().expect("open gate closes"); + assert!(stale.epoch().is_cancelled()); + assert!(stale.own_job().is_none()); + drop(stale); + assert_eq!( + gate.wait_for_idle(&close, Instant::now() + Duration::from_secs(1)), + DrainOutcome::Idle + ); + assert!(gate.reopen(&close)); + + let fresh = gate.admit().expect("rollback epoch admits"); + assert_ne!(fresh.epoch().id(), old_epoch); + assert!(!fresh.epoch().is_cancelled()); + } + + #[test] + fn miri_shutdown_invalidates_a_staged_replacement_permit() { + let coordinator = Arc::new(ReplacementCoordinator::default()); + let replacement = coordinator.acquire(); + assert!(replacement.is_current()); + { + let _shutdown = coordinator.begin_shutdown(); + assert!(!replacement.is_current()); + } + assert!(!replacement.is_current()); + } +} diff --git a/crates/gateway-stt/src/runtime.rs b/crates/gateway-stt/src/runtime.rs deleted file mode 100644 index 83968680..00000000 --- a/crates/gateway-stt/src/runtime.rs +++ /dev/null @@ -1,398 +0,0 @@ -//! Active-profile STT artifact provisioning and engine lifecycle. - -use std::path::PathBuf; -use std::sync::{Arc, PoisonError, RwLock}; - -use gateway_config::{Config, SttRole, WorkshopSttConfig}; -use gateway_local::artifacts::ArtifactStore; -use gateway_transcribe::{EngineConfig, SttEngine, SttSlot}; -use shared_progress::ProgressHandle; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum LoadedModelRole { - Interim, - Final, -} - -#[derive(Debug, Clone, Default)] -struct LoadedNames { - interim: Option, - final_model: Option, -} - -/// Shared active STT state used by both gateway HTTP surfaces. -/// -/// Clones observe the same engine and loaded-model names across profile -/// switches. -#[derive(Debug, Clone)] -pub struct SttState { - slot: SttSlot, - names: Arc>, - changes: tokio::sync::watch::Sender, -} - -impl Default for SttState { - fn default() -> Self { - let (changes, _receiver) = tokio::sync::watch::channel(0); - Self { - slot: SttSlot::default(), - names: Arc::new(RwLock::new(LoadedNames::default())), - changes, - } - } -} - -impl SttState { - pub(crate) fn engine(&self) -> Option> { - self.slot.engine() - } - - /// Returns whether an STT engine is active. - #[must_use] - pub fn is_active(&self) -> bool { - self.slot.is_active() - } - - pub(crate) fn select(&self, name: &str) -> Option<(Arc, LoadedModelRole)> { - let role = { - let names = self.names.read().unwrap_or_else(PoisonError::into_inner); - if names.interim.as_deref() == Some(name) { - Some(LoadedModelRole::Interim) - } else if names.final_model.as_deref() == Some(name) { - Some(LoadedModelRole::Final) - } else { - None - } - }?; - self.slot.engine().map(|engine| (engine, role)) - } - - pub(crate) fn subscribe(&self) -> tokio::sync::watch::Receiver { - self.changes.subscribe() - } - - fn activate(&self, engine: SttEngine, interim: String, final_model: Option) { - self.slot.activate(engine); - *self.names.write().unwrap_or_else(PoisonError::into_inner) = LoadedNames { - interim: Some(interim), - final_model, - }; - self.changes.send_modify(|generation| *generation += 1); - } - - fn take_engine(&self) -> Option> { - *self.names.write().unwrap_or_else(PoisonError::into_inner) = LoadedNames::default(); - let engine = self.slot.take(); - self.changes.send_modify(|generation| *generation += 1); - engine - } -} - -/// Gateway-owned runtime for the selected profile's STT pair. -/// -/// Dropping the runtime unloads its engine and releases the model memory. -#[derive(Debug)] -pub struct SttRuntime { - state: SttState, - active: bool, -} - -impl SttRuntime { - /// Creates an inactive runtime over `state`. - #[must_use] - pub fn empty(state: SttState) -> SttRuntime { - unload_engine(&state); - SttRuntime { - state, - active: false, - } - } - - /// Provisions the selected STT pair and loads its engine. - /// - /// A profile with no STT entries returns an inactive runtime. An - /// interim-only profile loads one worker and preserves the streaming - /// endpoint's degraded stop fallback. - /// - /// # Errors - /// Returns [`SttRuntimeError::WhisperLibrary`] or - /// [`SttRuntimeError::Artifact`] when runtime or model provisioning fails, - /// [`SttRuntimeError::MissingInterim`] when a final model has no interim - /// partner, or [`SttRuntimeError::Engine`] when whisper cannot load the - /// provisioned pair. - pub fn start( - config: &Config, - state: SttState, - progress: Option<&ProgressHandle>, - ) -> Result { - if config.stt_models().is_empty() { - return Ok(Self::empty(state)); - } - let cache = gateway_local::resolve_cache_root(config.local().cache_dir()) - .map_err(SttRuntimeError::Store)?; - let store = ArtifactStore::new(cache).map_err(SttRuntimeError::Store)?; - let library_progress = progress.map(|handle| handle.child("whisper-library", 1.0)); - let library = store - .provision_whisper_library(library_progress.as_ref()) - .map_err(SttRuntimeError::WhisperLibrary)?; - let models = provision_models(config, &store, progress)?; - let Some((interim_name, interim_path)) = models.interim else { - return Err(SttRuntimeError::MissingInterim); - }; - let capture = config - .workshop() - .and_then(gateway_config::WorkshopConfig::stt) - .cloned() - .unwrap_or_default(); - let engine_config = - engine_config(&capture, library, interim_path, models.final_model.as_ref()); - let engine = SttEngine::new_with_progress( - &engine_config, - progress.map(|handle| handle.child("engine", 1.0)), - ) - .map_err(SttRuntimeError::Engine)?; - let final_name = models.final_model.map(|(name, _)| name); - state.activate(engine, interim_name, final_name); - Ok(SttRuntime { - state, - active: true, - }) - } - - /// Returns shared state for HTTP routes. - #[must_use] - pub fn state(&self) -> SttState { - self.state.clone() - } - - /// Unloads the active engine immediately. - pub fn shutdown(mut self) { - self.clear(); - } - - fn clear(&mut self) { - if self.active { - unload_engine(&self.state); - self.active = false; - } - } -} - -impl Drop for SttRuntime { - fn drop(&mut self) { - self.clear(); - } -} - -fn unload_engine(state: &SttState) { - if let Some(engine) = state.take_engine() { - while Arc::strong_count(&engine) > 1 { - std::thread::sleep(std::time::Duration::from_millis(5)); - } - drop(engine); - } -} - -#[derive(Debug, Default)] -struct ProvisionedModels { - interim: Option<(String, PathBuf)>, - final_model: Option<(String, PathBuf)>, -} - -fn provision_models( - config: &Config, - store: &ArtifactStore, - progress: Option<&ProgressHandle>, -) -> Result { - let mut provisioned = ProvisionedModels::default(); - for model in config.stt_models() { - let model_progress = progress.map(|handle| handle.child(model.name(), 4.0)); - let path = store - .ensure_model_with_progress(model.source(), model.sha256(), model_progress.as_ref()) - .map_err(|source| SttRuntimeError::Artifact { - model: model.name().to_owned(), - source, - })?; - match model.role() { - SttRole::Interim => provisioned.interim = Some((model.name().to_owned(), path)), - SttRole::Final => provisioned.final_model = Some((model.name().to_owned(), path)), - _ => { - return Err(SttRuntimeError::UnsupportedRole { - model: model.name().to_owned(), - }); - } - } - } - Ok(provisioned) -} - -fn engine_config( - capture: &WorkshopSttConfig, - library: PathBuf, - interim_model: PathBuf, - final_model: Option<&(String, PathBuf)>, -) -> EngineConfig { - EngineConfig { - library, - interim_model, - final_model: final_model.map(|(_, path)| path.clone()), - vocabulary: capture.vocabulary().to_vec(), - window_seconds: capture.window_seconds(), - interval_ms: capture.interval_ms(), - } -} - -/// An STT runtime startup failure. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum SttRuntimeError { - /// The artifact store could not be opened. - #[non_exhaustive] - #[error("open STT artifact store")] - Store(#[source] gateway_local::LocalError), - - /// The platform whisper.cpp runtime could not be provisioned. - #[non_exhaustive] - #[error("provision whisper library")] - WhisperLibrary(#[source] gateway_local::LocalError), - - /// One model could not be provisioned. - #[non_exhaustive] - #[error("provision STT model {model}")] - Artifact { - /// Catalog name of the model that failed. - model: String, - /// Artifact download, confinement, or verification failure. - #[source] - source: gateway_local::LocalError, - }, - - /// A final model was selected without its required interim partner. - #[error("final STT model requires an interim model")] - MissingInterim, - - /// A future role reached a runtime that does not implement it. - #[non_exhaustive] - #[error("STT model {model} has an unsupported role")] - UnsupportedRole { - /// Catalog name carrying the unsupported role. - model: String, - }, - - /// The provisioned whisper pair could not be loaded. - #[non_exhaustive] - #[error("load STT engine")] - Engine(#[source] gateway_transcribe::TranscribeError), -} - -#[cfg(test)] -mod tests { - use std::fmt::Write as _; - - use sha2::{Digest, Sha256}; - - use super::*; - - fn selected(source: &str, sha256: Option<&str>) -> Config { - let pin = sha256.map_or_else(String::new, |pin| format!("sha256 = \"{pin}\"\n")); - let catalog = Config::from_toml_str(&format!( - "config-version = 2\n\ - [server]\nbind = \"127.0.0.1:0\"\napi_key = \"k\"\n\ - [workshop]\n\ - [[stt_model]]\nname = \"speech\"\nrole = \"interim\"\nsource = {source:?}\n\ - {pin}vram_gb = 1.0\n\ - [[profile]]\nname = \"work\"\nmodels = [\"speech\"]\n" - )) - .expect("catalog parses"); - catalog - .select_profile(&gateway_config::ProfileName::parse("work").expect("name")) - .expect("profile selects") - } - - #[test] - fn a_pinned_model_rejects_the_wrong_digest() { - let dir = tempfile::tempdir().expect("tempdir"); - let model = dir.path().join("model.bin"); - std::fs::write(&model, b"model bytes").expect("fixture writes"); - let wrong = "0".repeat(64); - let config = selected(&model.display().to_string(), Some(&wrong)); - let store = ArtifactStore::new(dir.path().join("cache")).expect("store builds"); - let error = provision_models(&config, &store, None).expect_err("bad pin must fail"); - assert!(matches!(error, SttRuntimeError::Artifact { .. })); - } - - #[test] - fn an_unpinned_local_model_provisions() { - let dir = tempfile::tempdir().expect("tempdir"); - let model = dir.path().join("model.bin"); - std::fs::write(&model, b"model bytes").expect("fixture writes"); - let config = selected(&model.display().to_string(), None); - let store = ArtifactStore::new(dir.path().join("cache")).expect("store builds"); - let provisioned = provision_models(&config, &store, None).expect("unpinned path works"); - assert_eq!( - provisioned.interim.as_ref().map(|(_, path)| path), - Some(&model) - ); - } - - #[test] - fn a_pinned_model_accepts_the_matching_digest() { - let dir = tempfile::tempdir().expect("tempdir"); - let model = dir.path().join("model.bin"); - std::fs::write(&model, b"model bytes").expect("fixture writes"); - let mut pin = String::with_capacity(64); - for byte in Sha256::digest(b"model bytes") { - write!(&mut pin, "{byte:02x}").expect("writing to String is infallible"); - } - let config = selected(&model.display().to_string(), Some(&pin)); - let store = ArtifactStore::new(dir.path().join("cache")).expect("store builds"); - let provisioned = provision_models(&config, &store, None).expect("matching pin works"); - assert_eq!( - provisioned.interim.as_ref().map(|(_, path)| path), - Some(&model) - ); - } - - #[test] - fn an_empty_runtime_clears_a_previously_loaded_name_table() { - let state = SttState::default(); - *state.names.write().unwrap_or_else(PoisonError::into_inner) = LoadedNames { - interim: Some("old".to_owned()), - final_model: None, - }; - let runtime = SttRuntime::empty(state.clone()); - assert!(state.select("old").is_none()); - runtime.shutdown(); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn switch_in_loads_and_switch_out_fully_unloads_the_engine() { - let dir = tempfile::tempdir().expect("tempdir"); - let source = gateway_transcribe::fixtures::require_model() - .display() - .to_string() - .replace('\\', "/"); - let cache = dir.path().display().to_string().replace('\\', "/"); - let catalog = Config::from_toml_str(&format!( - "config-version = 2\n\ - [server]\nbind = \"127.0.0.1:0\"\napi_key = \"k\"\n\ - [local]\ncache_dir = {cache:?}\n\ - [workshop]\n\ - [[stt_model]]\nname = \"speech\"\nrole = \"interim\"\nsource = {source:?}\n\ - vram_gb = 1.0\n\ - [[profile]]\nname = \"work\"\nmodels = [\"speech\"]\n" - )) - .expect("catalog parses"); - let config = catalog - .select_profile(&gateway_config::ProfileName::parse("work").expect("name")) - .expect("profile selects"); - let state = SttState::default(); - let runtime = SttRuntime::start(&config, state.clone(), None).expect("engine loads"); - assert!(state.is_active(), "switch-in activates the engine"); - assert!(state.select("speech").is_some(), "loaded name selects"); - runtime.shutdown(); - assert!(!state.is_active(), "switch-out drops the engine"); - assert!(state.select("speech").is_none(), "switch-out clears names"); - } -} diff --git a/crates/gateway-transcribe/src/segment.rs b/crates/gateway-stt/src/segment.rs similarity index 79% rename from crates/gateway-transcribe/src/segment.rs rename to crates/gateway-stt/src/segment.rs index d35fac9b..9a082586 100644 --- a/crates/gateway-transcribe/src/segment.rs +++ b/crates/gateway-stt/src/segment.rs @@ -11,20 +11,26 @@ use std::ops::Range; -use crate::{SAMPLE_RATE, is_silence}; +use gateway_stt_engine::EnginePolicy; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum SegmentOutcome { + Decode(Range), + Skipped(Range), +} /// Analysis frame length: 30 ms at 16 kHz, whisper.cpp's own VAD frame. -const FRAME_SAMPLES: usize = SAMPLE_RATE * 30 / 1000; +const FRAME_SAMPLES: usize = EnginePolicy::SAMPLE_RATE * 30 / 1000; /// Silence must persist this long after speech to close a segment: 700 ms, /// long enough to survive sentence-internal pauses and natural breathing /// gaps (~2 s), short enough that the final pass starts well before the /// user stops talking. -const MIN_SILENCE_SAMPLES: usize = SAMPLE_RATE * 2; +const MIN_SILENCE_SAMPLES: usize = EnginePolicy::SAMPLE_RATE * 2; /// Speech shorter than 250 ms is discarded as a click or cough rather than /// transcribed, where whisper would hallucinate a word for it. -const MIN_SPEECH_SAMPLES: usize = SAMPLE_RATE / 4; +const MIN_SPEECH_SAMPLES: usize = EnginePolicy::SAMPLE_RATE / 4; /// Incremental speech segmenter over one take's PCM buffer. /// @@ -32,7 +38,7 @@ const MIN_SPEECH_SAMPLES: usize = SAMPLE_RATE / 4; /// a cursor into it and each [`poll`](Segmenter::poll) scans only frames /// completed since the last call. Ranges are indices into that buffer. #[derive(Debug, Default)] -pub struct Segmenter { +pub(crate) struct Segmenter { /// Next unscanned sample index. cursor: usize, /// Start of the speech run currently being tracked, if any. @@ -47,30 +53,31 @@ pub struct Segmenter { impl Segmenter { /// A fresh segmenter positioned at the start of a take buffer. #[must_use] - pub fn new() -> Self { + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) fn new() -> Self { Self::default() } - /// Rewinds the segmenter for a new take; the caller clears the buffer at /// the same time, so indices stay aligned. - pub fn reset(&mut self) { + #[cfg(test)] + pub(crate) fn reset(&mut self) { *self = Self::new(); } /// Index past which all audio has been segmented; the unprocessed tail /// of the take is `buffer[self.consumed()..]`. #[must_use] - pub fn consumed(&self) -> usize { + pub(crate) fn consumed(&self) -> usize { self.consumed } /// Scans newly arrived frames and returns the range of the next /// completed speech segment, if one closed. Call in a loop: a large /// arrival can complete more than one segment. - pub fn poll(&mut self, buffer: &[f32]) -> Option> { + pub(crate) fn poll(&mut self, buffer: &[f32]) -> Option { while self.cursor + FRAME_SAMPLES <= buffer.len() { let frame = &buffer[self.cursor..self.cursor + FRAME_SAMPLES]; - let silent = is_silence(frame); + let silent = EnginePolicy::is_silence(frame); match (self.speech_start, silent) { (Some(start), true) => { let begin = self.silence_begin.get_or_insert(self.cursor); @@ -81,10 +88,9 @@ impl Segmenter { self.cursor += FRAME_SAMPLES; self.consumed = end; if end - start >= MIN_SPEECH_SAMPLES { - return Some(start..end); + return Some(SegmentOutcome::Decode(start..end)); } - // A click: consumed past it, nothing to transcribe. - continue; + return Some(SegmentOutcome::Skipped(start..end)); } } (None, false) => { @@ -107,12 +113,12 @@ mod tests { /// One second of loud synthetic speech (a constant 0.5 tone). fn speech(seconds: usize) -> Vec { - vec![0.5; seconds * SAMPLE_RATE] + vec![0.5; seconds * EnginePolicy::SAMPLE_RATE] } /// One second of digital silence. fn silence(seconds: usize) -> Vec { - vec![0.0; seconds * SAMPLE_RATE] + vec![0.0; seconds * EnginePolicy::SAMPLE_RATE] } /// Concatenates blocks of speech and silence into one buffer. @@ -123,8 +129,10 @@ mod tests { /// Drains every segment the segmenter can close over `buffer`. fn close_all(segmenter: &mut Segmenter, buffer: &[f32]) -> Vec> { let mut ranges = Vec::new(); - while let Some(range) = segmenter.poll(buffer) { - ranges.push(range); + while let Some(outcome) = segmenter.poll(buffer) { + if let SegmentOutcome::Decode(range) = outcome { + ranges.push(range); + } } ranges } @@ -157,11 +165,11 @@ mod tests { let range = &ranges[0]; assert_eq!(range.start, 0); assert!( - range.end <= 2 * SAMPLE_RATE + FRAME_SAMPLES, + range.end <= 2 * EnginePolicy::SAMPLE_RATE + FRAME_SAMPLES, "the segment ends where the silence began: {range:?}" ); assert!( - range.end - range.start >= 2 * SAMPLE_RATE - FRAME_SAMPLES, + range.end - range.start >= 2 * EnginePolicy::SAMPLE_RATE - FRAME_SAMPLES, "the segment holds the whole speech run: {range:?}" ); assert_eq!(segmenter.consumed(), range.end); @@ -181,11 +189,21 @@ mod tests { #[test] fn clicks_shorter_than_min_speech_are_discarded() { // 100 ms of tone followed by a full closing silence. - let buffer = take(&[speech(1).split_at(SAMPLE_RATE / 10).0.to_vec(), silence(3)]); + let buffer = take(&[ + speech(1) + .split_at(EnginePolicy::SAMPLE_RATE / 10) + .0 + .to_vec(), + silence(3), + ]); let mut segmenter = Segmenter::new(); - assert!( - close_all(&mut segmenter, &buffer).is_empty(), - "a 100 ms blip is a click, not a segment" + let outcome = segmenter + .poll(&buffer) + .expect("the discarded click is an explicit outcome"); + assert_eq!( + outcome, + SegmentOutcome::Skipped(0..EnginePolicy::SAMPLE_RATE * 3 / 25), + "the frame-aligned click coverage is retained for reconciliation" ); assert!( segmenter.consumed() > 0, @@ -212,7 +230,10 @@ mod tests { let mut segmenter = Segmenter::new(); assert!(segmenter.poll(&buffer).is_none()); buffer.extend_from_slice(&silence(3)); - let first = segmenter.poll(&buffer).expect("the segment closes"); + let SegmentOutcome::Decode(first) = segmenter.poll(&buffer).expect("the segment closes") + else { + panic!("ordinary speech is decoded"); + }; assert_eq!(first.start, 0); // Polling again without new audio returns nothing. assert!(segmenter.poll(&buffer).is_none()); diff --git a/crates/gateway-stt/src/service.rs b/crates/gateway-stt/src/service.rs new file mode 100644 index 00000000..84834c3b --- /dev/null +++ b/crates/gateway-stt/src/service.rs @@ -0,0 +1,126 @@ +//! Cloneable host facade for speech lifecycle, facts, and routes. + +use gateway_config::Config; +use shared_progress::ProgressHandle; + +use crate::artifacts::{self, PreparedSpeech, SpeechError}; +use crate::generation::{GenerationState, SpeechReplacement}; +use crate::model::SpeechModelInfo; +#[cfg(feature = "test-fixtures")] +use crate::realtime::ForcedPrecommitFailure; +use crate::realtime::{RoutePolicy, SessionRegistry}; +use crate::status::SpeechStatus; + +/// Cloneable Gateway handle for all speech behavior. +#[derive(Debug, Clone, Default)] +pub struct SpeechService { + pub(crate) state: GenerationState, + sessions: SessionRegistry, + realtime_policy: RoutePolicy, +} + +impl SpeechService { + /// Creates an inactive service. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Verifies and stages configured artifacts without starting workers. + /// + /// # Errors + /// Returns a typed store, download, verification, or configuration error. + pub fn prepare( + &self, + config: &Config, + progress: Option<&ProgressHandle>, + ) -> Result { + artifacts::prepare(config, progress) + } + + /// Serializes replacement, drains old ownership, and loads a staged generation. + /// + /// # Errors + /// Returns a drain deadline, backend, policy, or worker startup error. + pub fn begin_replacement( + &self, + prepared: PreparedSpeech, + ) -> Result { + self.state.stage(prepared) + } + + /// Serializes replacement and constrains drain plus worker startup to one deadline. + /// + /// # Errors + /// Returns a drain deadline, backend, policy, or worker startup error. + pub fn begin_replacement_before( + &self, + prepared: PreparedSpeech, + deadline: std::time::Instant, + ) -> Result { + self.state.stage_until(prepared, deadline) + } + + /// Publishes every fact in a staged generation through one transition. + /// + /// # Errors + /// Returns an ownership error for a foreign or shutdown-invalidated token. + pub fn commit_replacement(&self, replacement: SpeechReplacement) -> Result<(), SpeechError> { + self.state.commit(replacement) + } + + /// Stops a staged generation and reconstructs the old specification. + /// + /// # Errors + /// Returns an ownership, shutdown, or old-generation reconstruction error. + pub fn abort_replacement(&self, replacement: SpeechReplacement) -> Result<(), SpeechError> { + self.state.abort(replacement) + } + + /// Stops admitting work and waits for the active generation to unload. + pub fn shutdown(&self) { + self.state.shutdown(); + } + + /// Returns one point-in-time status snapshot. + #[must_use] + pub fn status(&self) -> SpeechStatus { + self.state.status() + } + + /// Returns physical batch models and any ready logical model from one snapshot. + #[must_use] + pub fn models(&self) -> Vec { + self.state.models() + } + + /// Blocks Realtime sends after `successful_sends` for deadline tests. + #[cfg(feature = "test-fixtures")] + pub fn block_realtime_send_after(&mut self, successful_sends: usize) { + self.realtime_policy = RoutePolicy::blocking_after(successful_sends); + } + + /// Forces a typed precommit transcription failure for route tests. + #[cfg(feature = "test-fixtures")] + pub fn fail_realtime_precommit(&mut self) { + self.realtime_policy + .force_precommit_failure(ForcedPrecommitFailure::Transcription); + } + + /// Forces a typed final-segment overload for route tests. + #[cfg(feature = "test-fixtures")] + pub fn overload_realtime_final_segment(&mut self) { + self.realtime_policy + .force_precommit_failure(ForcedPrecommitFailure::FinalSegmentOverload); + } + + /// Returns the batch and Realtime Gateway routes. + #[cfg(not(miri))] + pub fn routes(&self) -> axum::Router { + crate::batch::routes(self.state.clone()).merge(crate::realtime::routes( + self.state.clone(), + self.sessions.clone(), + self.realtime_policy.clone(), + )) + } +} diff --git a/crates/gateway-stt/src/status.rs b/crates/gateway-stt/src/status.rs new file mode 100644 index 00000000..d18f166d --- /dev/null +++ b/crates/gateway-stt/src/status.rs @@ -0,0 +1,54 @@ +//! Point-in-time speech service status. + +/// Generic facts about the active speech generation. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct SpeechStatus { + configured: bool, + ready: bool, + gpu: bool, + generation: Option, +} + +impl SpeechStatus { + pub(crate) const fn unready(configured: bool) -> Self { + Self { + configured, + ready: false, + gpu: false, + generation: None, + } + } + + pub(crate) const fn active(gpu: bool, generation: u64) -> Self { + Self { + configured: true, + ready: true, + gpu, + generation: Some(generation), + } + } + + /// Returns whether the active profile configures speech. + #[must_use] + pub const fn configured(self) -> bool { + self.configured + } + + /// Returns whether one complete generation accepts requests. + #[must_use] + pub const fn ready(self) -> bool { + self.ready + } + + /// Returns whether the active backend reports GPU acceleration. + #[must_use] + pub const fn gpu(self) -> bool { + self.gpu + } + + /// Returns the active generation identifier. + #[must_use] + pub const fn generation(self) -> Option { + self.generation + } +} diff --git a/crates/gateway-stt/src/stt.rs b/crates/gateway-stt/src/stt.rs deleted file mode 100644 index 9bcaadb6..00000000 --- a/crates/gateway-stt/src/stt.rs +++ /dev/null @@ -1,854 +0,0 @@ -//! The `/stt` WebSocket endpoint with its existing streaming wire contract. - -use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; -use std::time::{Duration, Instant}; - -use axum::Router; -use axum::extract::State; -use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; -use axum::http::{HeaderMap, StatusCode, header}; -use axum::response::{IntoResponse, Response}; -use axum::routing::get; -use gateway_transcribe::{MIN_WINDOW_SAMPLES, SAMPLE_RATE, Segmenter, SttEngine, is_silence, tail}; -use serde::Serialize; -use tokio::sync::{mpsc, watch}; -use workshop_server::{Activity, Push}; - -use crate::runtime::SttState; - -static NEXT_SESSION: AtomicU64 = AtomicU64::new(1); -const MIC_PULSE_INTERVAL: Duration = Duration::from_millis(250); -const STT_START: &str = "start"; -const STT_STOP: &str = "stop"; -const WORKSHOP_STATUS_HEADER: &str = "x-promptforge-workshop-status"; - -#[derive(Debug, Clone)] -struct RouteState { - stt: SttState, - reporter: Reporter, -} - -#[derive(Debug, Clone)] -enum Reporter { - Workshop(Push), - Socket(mpsc::UnboundedSender), - Silent, -} - -#[derive(Debug, Serialize)] -struct RelayedStatusFrame { - #[serde(rename = "type")] - kind: &'static str, - label: String, - description: String, - severity: &'static str, -} - -impl Reporter { - fn push_status_update( - &self, - label: impl Into, - description: impl Into, - activity: Activity, - ) { - match self { - Self::Workshop(push) => push.push_status_update(label, description, activity), - Self::Socket(statuses) => { - relay_status(statuses, label, description, "info"); - } - Self::Silent => {} - } - } - - fn push_failure( - &self, - label: impl Into, - description: impl Into, - activity: Activity, - ) { - match self { - Self::Workshop(push) => push.push_failure(label, description, activity), - Self::Socket(statuses) => { - relay_status(statuses, label, description, "error"); - } - Self::Silent => {} - } - } - - fn push_activity( - &self, - label: impl Into, - description: impl Into, - activity: Activity, - ) { - match self { - Self::Workshop(push) => push.push_activity(label, description, activity), - Self::Socket(statuses) => { - relay_status(statuses, label, description, "debug"); - } - Self::Silent => {} - } - } - - fn push_idle(&self) { - match self { - Self::Workshop(push) => push.push_idle(), - Self::Socket(statuses) => { - relay_status(statuses, "Ready", "idle", "info"); - } - Self::Silent => {} - } - } -} - -fn relay_status( - statuses: &mpsc::UnboundedSender, - label: impl Into, - description: impl Into, - severity: &'static str, -) { - let frame = RelayedStatusFrame { - kind: "workshop_status", - label: label.into(), - description: description.into(), - severity, - }; - if let Ok(message) = serde_json::to_string(&frame) { - let _ = statuses.send(message); - } -} - -/// Builds the workshop-listener STT routes. -/// -/// The routes serve `/stt` and `/stt/capability`. The shared workshop -/// cross-site guard protects both routes, and the upgrade performs the -/// existing explicit Origin check as a second WebSocket-specific layer. -pub fn routes(stt: SttState, push: Push) -> Router { - routes_with_reporter(stt, Reporter::Workshop(push)) - .route_layer(axum::middleware::from_fn(workshop_server::cross_site_guard)) -} - -/// Builds the gateway-listener STT routes. -/// -/// Session activity is multiplexed as private `workshop_status` frames for -/// the Workshop relay to consume. Its host is responsible for authenticating -/// both routes before merging them. -pub fn gateway_routes(stt: SttState) -> Router { - routes_with_reporter(stt, Reporter::Silent) -} - -fn routes_with_reporter(stt: SttState, reporter: Reporter) -> Router { - Router::new() - .route("/stt/capability", get(capability)) - .route("/stt", get(upgrade)) - .with_state(RouteState { stt, reporter }) -} - -async fn capability(State(state): State) -> impl IntoResponse { - let engine = state.stt.engine(); - let gpu = engine - .as_ref() - .is_some_and(|engine| engine.gpu_transcription_available()); - let engine = engine.is_some(); - ( - [(header::CONTENT_TYPE, "application/json")], - format!(r#"{{"gpu":{gpu},"engine":{engine}}}"#), - ) -} - -async fn upgrade( - State(state): State, - headers: HeaderMap, - ws: WebSocketUpgrade, -) -> Response { - if !workshop_server::origin_allowed(&headers) { - return StatusCode::FORBIDDEN.into_response(); - } - let relay_status = headers - .get(WORKSHOP_STATUS_HEADER) - .is_some_and(|value| value == "1"); - ws.on_upgrade(move |socket| { - let (reporter, statuses) = match (state.reporter, relay_status) { - (Reporter::Silent, true) => { - let (tx, rx) = mpsc::unbounded_channel(); - (Reporter::Socket(tx), Some(rx)) - } - (reporter, _) => (reporter, None), - }; - run_session(socket, state.stt, reporter, statuses) - }) -} - -#[derive(Debug, Serialize)] -struct StreamFrame { - #[serde(rename = "type")] - kind: &'static str, - generation: u64, -} - -impl StreamFrame { - fn new(generation: u64) -> Self { - Self { - kind: "stream", - generation, - } - } -} - -#[derive(Debug, Serialize)] -struct InterimFrame { - #[serde(rename = "type")] - kind: &'static str, - committed: String, - tentative: String, - generation: u64, -} - -impl InterimFrame { - fn new(committed: String, tentative: String, generation: u64) -> Self { - Self { - kind: "interim", - committed, - tentative, - generation, - } - } -} - -#[derive(Debug, Serialize)] -struct FinalFrame { - #[serde(rename = "type")] - kind: &'static str, - text: String, - frames: u64, - generation: u64, -} - -impl FinalFrame { - fn new(text: String, frames: u64, generation: u64) -> Self { - Self { - kind: "final", - text, - frames, - generation, - } - } -} - -fn append_transcript(text: &mut String, piece: &str) { - if piece.is_empty() { - return; - } - if !text.is_empty() { - text.push(' '); - } - text.push_str(piece); -} - -#[derive(Debug, Default)] -struct Committed { - text: String, - segments: Option>, -} - -impl Committed { - fn drain(&mut self) { - if let Some(segments) = &self.segments { - while let Ok(text) = segments.try_recv() { - append_transcript(&mut self.text, &text); - } - } - } -} - -#[derive(Debug, Default)] -struct TakeState { - buffer: Mutex>, - committed: Mutex, - consumed: AtomicUsize, -} - -impl TakeState { - fn lock_buffer(&self) -> MutexGuard<'_, Vec> { - self.buffer.lock().unwrap_or_else(PoisonError::into_inner) - } - - fn lock_committed(&self) -> MutexGuard<'_, Committed> { - self.committed - .lock() - .unwrap_or_else(PoisonError::into_inner) - } - - fn reset(&self, segments: Option>) { - self.lock_buffer().clear(); - self.consumed.store(0, Ordering::Relaxed); - let mut committed = self.lock_committed(); - committed.text.clear(); - committed.segments = segments; - } - - fn uncommitted_snapshot(&self, consumed: usize, window_samples: usize) -> Vec { - let guard = self.lock_buffer(); - let uncommitted = &guard[consumed.min(guard.len())..]; - tail(uncommitted, window_samples).to_vec() - } -} - -#[derive(Debug)] -struct ActiveTake { - interims: watch::Receiver>, - _task: InterimTask, -} - -#[derive(Debug)] -struct InterimTask(tokio::task::JoinHandle<()>); - -impl Drop for InterimTask { - fn drop(&mut self) { - self.0.abort(); - } -} - -async fn next_interim(take: &mut Option) -> Option { - match take.as_mut() { - Some(active) => match active.interims.changed().await { - Ok(()) => active.interims.borrow_and_update().clone(), - Err(_) => std::future::pending().await, - }, - None => std::future::pending().await, - } -} - -async fn next_status(statuses: &mut Option>) -> Option { - match statuses { - Some(statuses) => statuses.recv().await, - None => std::future::pending().await, - } -} - -fn spawn_interim( - session: u64, - generation: u64, - engine: Arc, - state: Arc, - reporter: Reporter, -) -> ActiveTake { - let (interim_tx, interims) = watch::channel(None); - let task = InterimTask(tokio::spawn(async move { - let mut last_committed = String::new(); - let mut last_tentative = String::new(); - let mut committed_at_last_speech = String::new(); - loop { - tokio::time::sleep(engine.interval()).await; - let committed_text = { - let mut guard = state.lock_committed(); - guard.drain(); - guard.text.clone() - }; - let window = state.uncommitted_snapshot( - state.consumed.load(Ordering::Relaxed), - engine.window_samples(), - ); - let tentative = if window.len() < MIN_WINDOW_SAMPLES || is_silence(&window) { - String::new() - } else { - reporter.push_activity( - "Transcribing...", - "an interim pass over the uncommitted audio", - Activity::General, - ); - match engine.transcribe(window).await { - Ok(text) => text, - Err(error) => { - reporter.push_activity( - "Transcription failed", - error.to_string(), - Activity::General, - ); - tracing::warn!(session, %error, "interim transcription failed"); - continue; - } - } - }; - if !tentative.is_empty() { - committed_at_last_speech.clone_from(&committed_text); - } else if committed_text.len() <= committed_at_last_speech.len() { - continue; - } - if committed_text == last_committed && tentative == last_tentative { - continue; - } - last_committed.clone_from(&committed_text); - last_tentative.clone_from(&tentative); - let Ok(message) = - serde_json::to_string(&InterimFrame::new(committed_text, tentative, generation)) - else { - continue; - }; - if interim_tx.send(Some(message)).is_err() { - return; - } - } - })); - ActiveTake { - interims, - _task: task, - } -} - -async fn final_transcript( - session: u64, - engine: &SttEngine, - state: &TakeState, - segmenter: &Segmenter, - reporter: &Reporter, -) -> String { - let window = state.uncommitted_snapshot(segmenter.consumed(), engine.window_samples()); - if window.len() < MIN_WINDOW_SAMPLES || is_silence(&window) { - return String::new(); - } - match engine.transcribe(window).await { - Ok(text) => text, - Err(error) => { - reporter.push_failure("Transcription failed", error.to_string(), Activity::General); - tracing::warn!(session, %error, "final transcription failed"); - String::new() - } - } -} - -/// The dropped leading samples when a take's uncommitted audio exceeds one -/// interim window, or `None` when the whole take fits. -fn truncation_drop(uncommitted: usize, window_samples: usize) -> Option { - if uncommitted > window_samples { - Some(uncommitted - window_samples) - } else { - None - } -} - -/// The status-bar description of one truncation: the window length and the -/// dropped lead, both in seconds (the lead to a truncated tenth). -fn truncation_message(window_samples: usize, dropped: usize) -> String { - format!( - "the take ran past the {} s interim window with no final transcription, so its first {}.{} s were dropped", - window_samples / SAMPLE_RATE, - dropped / SAMPLE_RATE, - dropped % SAMPLE_RATE * 10 / SAMPLE_RATE, - ) -} - -/// The interim-window fallback transcribes only the take's last window of -/// audio; a longer take loses its leading audio. Name the truncation on the -/// status bar and in the log instead of dropping it silently. -fn warn_if_truncated( - session: u64, - engine: &SttEngine, - state: &TakeState, - segmenter: &Segmenter, - reporter: &Reporter, -) { - let uncommitted = { - let guard = state.lock_buffer(); - guard.len().saturating_sub(segmenter.consumed()) - }; - let window = engine.window_samples(); - let Some(dropped) = truncation_drop(uncommitted, window) else { - return; - }; - tracing::warn!( - session, - dropped_samples = dropped, - window_samples = window, - "take exceeded the interim window; leading audio dropped from the transcript" - ); - reporter.push_failure( - "Transcript truncated", - truncation_message(window, dropped), - Activity::General, - ); -} - -async fn stop_transcript( - session: u64, - engine: Option<&SttEngine>, - state: &TakeState, - segmenter: &Segmenter, - reporter: &Reporter, -) -> String { - let Some(engine) = engine else { - return String::new(); - }; - let tail = { - let guard = state.lock_buffer(); - guard[segmenter.consumed()..].to_vec() - }; - let tail = match engine.final_finish(tail).await { - Some(Ok(text)) => text, - Some(Err(error)) => { - reporter.push_failure("Transcription failed", error.to_string(), Activity::General); - tracing::warn!( - session, - %error, - "final-pass transcription failed; falling back to the interim model" - ); - warn_if_truncated(session, engine, state, segmenter, reporter); - final_transcript(session, engine, state, segmenter, reporter).await - } - None => { - tracing::info!( - session, - "no final model configured; the final pass uses the interim model" - ); - warn_if_truncated(session, engine, state, segmenter, reporter); - final_transcript(session, engine, state, segmenter, reporter).await - } - }; - let mut guard = state.lock_committed(); - guard.drain(); - append_transcript(&mut guard.text, &tail); - guard.text.clone() -} - -fn begin_take( - session: u64, - generation: u64, - engine: Option<&Arc>, - state: &Arc, - segmenter: &mut Segmenter, - reporter: &Reporter, -) -> Option { - segmenter.reset(); - let segments = engine - .filter(|engine| engine.has_final_pass()) - .map(|engine| { - let (segment_tx, segment_rx) = std::sync::mpsc::channel(); - engine.final_reset(segment_tx); - segment_rx - }); - state.reset(segments); - let take = engine.map(|engine| { - spawn_interim( - session, - generation, - Arc::clone(engine), - Arc::clone(state), - reporter.clone(), - ) - }); - reporter.push_status_update( - "Listening...", - "a push-to-talk take is recording", - Activity::General, - ); - tracing::info!(session, "stt capture started"); - take -} - -fn submit_closed_segments(engine: &SttEngine, state: &TakeState, segmenter: &mut Segmenter) { - loop { - let segment = { - let guard = state.lock_buffer(); - segmenter.poll(&guard).map(|range| guard[range].to_vec()) - }; - match segment { - Some(samples) => engine.final_submit(samples), - None => break, - } - } - state - .consumed - .store(segmenter.consumed(), Ordering::Relaxed); - state.lock_committed().drain(); -} - -async fn send_frame(socket: &mut WebSocket, frame: &F) -> bool { - let Ok(text) = serde_json::to_string(frame) else { - return true; - }; - send_text(socket, text).await -} - -async fn send_text(socket: &mut WebSocket, text: String) -> bool { - socket.send(Message::Text(text.into())).await.is_ok() -} - -struct SessionClose { - session: u64, - reporter: Reporter, -} - -impl Drop for SessionClose { - fn drop(&mut self) { - self.reporter.push_idle(); - tracing::info!(session = self.session, "stt session closed"); - } -} - -struct SessionAudio { - state: Arc, - segmenter: Segmenter, - frames: u64, - last_mic_pulse: Option, -} - -impl SessionAudio { - fn new() -> Self { - Self { - state: Arc::new(TakeState::default()), - segmenter: Segmenter::new(), - frames: 0, - last_mic_pulse: None, - } - } - - fn receive(&mut self, payload: &[u8], engine: Option<&SttEngine>, reporter: &Reporter) { - let samples: Vec = payload - .as_chunks::<4>() - .0 - .iter() - .map(|bytes| f32::from_le_bytes(*bytes)) - .collect(); - self.frames += samples.len() as u64; - self.state.lock_buffer().extend_from_slice(&samples); - if self - .last_mic_pulse - .is_none_or(|at| at.elapsed() >= MIC_PULSE_INTERVAL) - { - self.last_mic_pulse = Some(Instant::now()); - reporter.push_activity( - "Listening...", - "microphone audio is arriving", - Activity::General, - ); - } - if let Some(engine) = engine - && engine.has_final_pass() - { - submit_closed_segments(engine, &self.state, &mut self.segmenter); - } - } -} - -async fn run_session( - mut socket: WebSocket, - stt: SttState, - reporter: Reporter, - mut statuses: Option>, -) { - let session = NEXT_SESSION.fetch_add(1, Ordering::Relaxed); - tracing::info!(session, "stt session opened"); - let _closed = SessionClose { - session, - reporter: reporter.clone(), - }; - - let mut audio = SessionAudio::new(); - let mut take: Option = None; - let mut engine = stt.engine(); - let mut engine_changes = stt.subscribe(); - let mut generation = 0u64; - - loop { - tokio::select! { - biased; - changed = engine_changes.changed() => { - if changed.is_err() { - break; - } - take = None; - audio.state.reset(None); - audio.segmenter.reset(); - engine = stt.engine(); - } - interim = next_interim(&mut take) => { - if let Some(text) = interim - && !send_text(&mut socket, text).await - { - break; - } - } - status = next_status(&mut statuses) => { - if let Some(text) = status - && !send_text(&mut socket, text).await - { - break; - } - } - inbound = socket.recv() => match inbound { - Some(Ok(Message::Binary(payload))) => { - audio.receive(&payload, engine.as_deref(), &reporter); - } - Some(Ok(Message::Text(text))) => match text.as_str() { - STT_START => { - audio.frames = 0; - audio.last_mic_pulse = None; - generation += 1; - drop(take.take()); - if !send_frame(&mut socket, &StreamFrame::new(generation)).await { - break; - } - take = begin_take( - session, - generation, - engine.as_ref(), - &audio.state, - &mut audio.segmenter, - &reporter, - ); - } - STT_STOP => { - take = None; - reporter.push_status_update( - "Finalizing transcript...", - "the final pass over the take", - Activity::General, - ); - let text = stop_transcript( - session, - engine.as_deref(), - &audio.state, - &audio.segmenter, - &reporter, - ) - .await; - tracing::info!(session, frames = audio.frames, "stt capture stopped"); - if !send_frame( - &mut socket, - &FinalFrame::new(text, audio.frames, generation), - ) - .await - { - break; - } - reporter.push_idle(); - } - _ => tracing::debug!(session, "ignoring an unknown stt control message"), - }, - Some(Ok(Message::Ping(_) | Message::Pong(_))) => {} - Some(Ok(Message::Close(_))) | None => break, - Some(Err(error)) => { - tracing::warn!(session, %error, "stt session socket failed"); - break; - } - }, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_stream_frame_serializes_its_generation() { - let frame = serde_json::to_value(StreamFrame::new(3)).expect("the frame serializes"); - assert_eq!( - frame, - serde_json::json!({ - "type": "stream", - "generation": 3, - }) - ); - } - - #[test] - fn an_interim_frame_serializes_both_transcript_fields() { - let frame = serde_json::to_value(InterimFrame::new( - "ask not".to_owned(), - "what you".to_owned(), - 1, - )) - .expect("the frame serializes"); - assert_eq!( - frame, - serde_json::json!({ - "type": "interim", - "committed": "ask not", - "tentative": "what you", - "generation": 1, - }) - ); - } - - #[test] - fn a_final_frame_serializes_the_transcript_and_the_frame_count() { - let frame = serde_json::to_value(FinalFrame::new(String::new(), 192, 2)) - .expect("the frame serializes"); - assert_eq!( - frame, - serde_json::json!({ - "type": "final", - "text": "", - "frames": 192, - "generation": 2, - }) - ); - } - - #[test] - fn the_stt_control_messages_are_bare_words() { - assert_eq!(STT_START, "start"); - assert_eq!(STT_STOP, "stop"); - } - - #[test] - fn truncation_starts_past_the_window() { - let window = 15 * SAMPLE_RATE; - assert_eq!(truncation_drop(0, window), None); - assert_eq!(truncation_drop(window, window), None); - assert_eq!(truncation_drop(window + 1, window), Some(1)); - assert_eq!( - truncation_drop(20 * SAMPLE_RATE, window), - Some(5 * SAMPLE_RATE) - ); - } - - #[test] - fn the_truncation_message_names_the_window_and_the_dropped_lead() { - let message = truncation_message(15 * SAMPLE_RATE, 5 * SAMPLE_RATE); - assert!(message.contains("15 s"), "{message}"); - assert!(message.contains("5.0 s"), "{message}"); - } - - #[test] - fn committed_drain_appends_segments_in_arrival_order() { - let (segment_tx, segment_rx) = std::sync::mpsc::channel(); - let mut committed = Committed { - text: String::new(), - segments: Some(segment_rx), - }; - segment_tx - .send("ask not".to_owned()) - .expect("receiver held"); - committed.drain(); - assert_eq!(committed.text, "ask not"); - segment_tx - .send("what you can do".to_owned()) - .expect("receiver held"); - committed.drain(); - assert_eq!(committed.text, "ask not what you can do"); - } - - #[tokio::test] - async fn a_lagging_loop_reads_only_the_newest_interim() { - let (interim_tx, interims) = watch::channel(None); - let mut take = Some(ActiveTake { - interims, - _task: InterimTask(tokio::spawn(std::future::pending::<()>())), - }); - interim_tx - .send(Some("old".to_owned())) - .expect("receiver held"); - interim_tx - .send(Some("new".to_owned())) - .expect("receiver held"); - assert_eq!(next_interim(&mut take).await.as_deref(), Some("new")); - assert!( - tokio::time::timeout(Duration::from_millis(50), next_interim(&mut take)) - .await - .is_err() - ); - } -} diff --git a/crates/gateway-stt/src/take.rs b/crates/gateway-stt/src/take.rs new file mode 100644 index 00000000..4fcd8897 --- /dev/null +++ b/crates/gateway-stt/src/take.rs @@ -0,0 +1,274 @@ +//! Per-take speech state and finalization ownership. + +use std::sync::Arc; +use std::sync::Mutex; + +#[cfg(test)] +use gateway_stt_engine::TranscribeError; + +use crate::generation::GenerationLease; + +mod agreement; +mod final_outcome; +mod finalization; +mod interim; +mod state; +mod text; +mod window; + +#[cfg(test)] +use finalization::{FINAL_SEGMENT_CAPACITY, FinalCommand, reserve_segment, run_final_pipeline}; +use finalization::{FinalPipeline, spawn_final_pipeline}; +pub(crate) use interim::InterimSnapshot; +use state::TakeState; +use window::WholeWindowState; + +#[cfg(any(test, feature = "test-fixtures"))] +fn tail(buffer: &[f32], window: usize) -> &[f32] { + &buffer[buffer.len().saturating_sub(window)..] +} + +#[derive(Debug)] +pub(crate) struct InterimAudioWindow { + pub(crate) samples: Vec, + pub(crate) start: usize, + pub(crate) end: usize, + pub(crate) segment_start: usize, +} + +/// All mutable and immutable state belonging to one speech take. +#[derive(Debug)] +pub(crate) struct Take { + guidance: Arc<[String]>, + state: Arc, + whole_window: Mutex, + final_pipeline: Option, +} + +impl Take { + pub(crate) fn new(guidance: Vec, engine: Option) -> Self { + let guidance = Arc::<[String]>::from(guidance); + let state = Arc::new(TakeState::default()); + let final_pipeline = engine + .filter(GenerationLease::has_final_pass) + .map(|engine| spawn_final_pipeline(engine, Arc::clone(&guidance), Arc::clone(&state))); + Self { + guidance, + state, + whole_window: Mutex::new(WholeWindowState::default()), + final_pipeline, + } + } + + #[cfg(test)] + fn without_final(guidance: Vec) -> Self { + Self::new(guidance, None) + } + + pub(crate) fn guidance(&self) -> &[String] { + &self.guidance + } + + pub(crate) fn append(&self, samples: &[f32]) { + TakeState::lock(&self.state.buffer).extend_from_slice(samples); + } + + pub(crate) fn submit_closed_segments(&self) { + if let Some(pipeline) = &self.final_pipeline { + pipeline.submit_closed_segments(&self.state); + } + } + + pub(crate) fn consumed(&self) -> usize { + TakeState::lock(&self.state.segmenter).consumed() + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) fn uncommitted_snapshot(&self, window_samples: usize) -> Vec { + let consumed = self.consumed(); + let buffer = TakeState::lock(&self.state.buffer); + let uncommitted = &buffer[consumed.min(buffer.len())..]; + tail(uncommitted, window_samples).to_vec() + } + + pub(crate) fn interim_window(&self, window_samples: usize) -> InterimAudioWindow { + let segment_start = self.consumed(); + let buffer = TakeState::lock(&self.state.buffer); + let end = buffer.len(); + let start = segment_start.max(end.saturating_sub(window_samples)); + InterimAudioWindow { + samples: buffer[start.min(end)..].to_vec(), + start, + end, + segment_start, + } + } + + pub(crate) fn finalized(&self) -> String { + self.state.finalized() + } + + pub(crate) fn next_window_snapshot( + &self, + hypothesis: &str, + segment_start: usize, + window_start: usize, + window_end: usize, + ) -> Option { + let (finalized, finalized_samples) = self.state.finalized_snapshot(); + TakeState::lock(&self.whole_window).next( + &finalized, + finalized_samples, + segment_start, + window_start, + window_end, + hypothesis, + ) + } + + #[cfg(test)] + fn record_finalized(&self, result: Result) { + self.state.record_finalized(result, None); + } + + pub(crate) fn record_failure(&self, failure: impl Into) { + self.state.record_failure(failure.into()); + } + + pub(crate) fn pending_failure(&self) -> Option { + self.state.pending_failure() + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) fn pending_final_segments(&self) -> usize { + self.final_pipeline + .as_ref() + .map_or(0, FinalPipeline::pending_segments) + } + + #[cfg(test)] + fn take_failure(&self) -> Option { + self.state.take_failure() + } + + pub(crate) fn finalization(&self) -> Option { + let pipeline = self.final_pipeline.as_ref()?; + let consumed = self.consumed(); + let buffer = TakeState::lock(&self.state.buffer); + let committed_samples = buffer.len(); + let tail = buffer[consumed.min(buffer.len())..].to_vec(); + drop(buffer); + let accepted = TakeState::lock(&self.whole_window).accepted_hypotheses(committed_samples); + Some(pipeline.finalization(tail, consumed, committed_samples, accepted)) + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Weak}; + use std::time::Duration; + + use tokio::sync::{mpsc, oneshot}; + + use super::{FinalCommand, Take, reserve_segment, run_final_pipeline}; + + #[test] + fn miri_final_segment_reservation_is_exact() { + let pending = AtomicUsize::new(0); + for _ in 0..super::FINAL_SEGMENT_CAPACITY { + assert!(reserve_segment(&pending)); + } + assert!(!reserve_segment(&pending)); + assert_eq!( + pending.load(Ordering::Acquire), + super::FINAL_SEGMENT_CAPACITY + ); + } + + #[test] + fn tail_returns_the_trailing_window() { + let buffer: Vec = (0u8..10).map(f32::from).collect(); + assert_eq!(super::tail(&buffer, 4), &[6.0, 7.0, 8.0, 9.0]); + assert_eq!(super::tail(&buffer, 100), &buffer); + assert_eq!(super::tail(&[], 4), &[] as &[f32]); + } + + #[test] + fn finalized_history_and_guidance_are_isolated_per_take() { + let first = Take::without_final(vec!["MCP".to_owned()]); + let second = Take::without_final(vec!["GGUF".to_owned()]); + + first.record_finalized(Ok("ask not".to_owned())); + second.record_finalized(Ok("what you".to_owned())); + + assert_eq!(first.guidance(), ["MCP"]); + assert_eq!(first.finalized(), "ask not"); + assert_eq!(second.guidance(), ["GGUF"]); + assert_eq!(second.finalized(), "what you"); + } + + #[test] + fn finalized_segments_aggregate_in_arrival_order() { + let take = Take::without_final(Vec::new()); + take.record_finalized(Ok("ask not".to_owned())); + take.record_finalized(Ok("what you can do".to_owned())); + assert_eq!(take.finalized(), "ask not what you can do"); + } + + #[test] + fn a_take_retains_its_first_final_failure() { + let take = Take::without_final(Vec::new()); + take.record_failure("first"); + take.record_failure("second"); + let failure = take.take_failure().expect("the take owns its failure"); + assert_eq!(failure, "first"); + } + + #[tokio::test] + async fn completed_pipeline_releases_its_retained_dependency() { + let (commands, receiver) = mpsc::channel(super::FINAL_SEGMENT_CAPACITY); + let state = Arc::new(super::TakeState::default()); + let retained = Arc::new(()); + let weak: Weak<()> = Arc::downgrade(&retained); + let pipeline_retained = Arc::clone(&retained); + let task = tokio::spawn(run_final_pipeline( + receiver, + Arc::from([]), + state, + Arc::new(AtomicUsize::new(0)), + move |_, _, _| { + let retained = Arc::clone(&pipeline_retained); + async move { + drop(retained); + Some(Ok(String::new())) + } + }, + )); + drop(retained); + let (reply, completion) = oneshot::channel(); + commands + .send(FinalCommand::Complete { + tail: Vec::new(), + start: 0, + committed_samples: 0, + accepted: Vec::new(), + reply, + }) + .await + .expect("completion queues"); + + assert_eq!( + completion.await.expect("the completion pipeline replies"), + Ok(String::new()) + ); + tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("the completed pipeline terminates before the deadline") + .expect("the completed pipeline task succeeds"); + assert!( + weak.upgrade().is_none(), + "pipeline completion releases its retained engine-like dependency" + ); + } +} diff --git a/crates/gateway-stt/src/take/agreement.rs b/crates/gateway-stt/src/take/agreement.rs new file mode 100644 index 00000000..e69f529f --- /dev/null +++ b/crates/gateway-stt/src/take/agreement.rs @@ -0,0 +1,30 @@ +pub(super) fn matching_token_prefix_end(previous: &str, current: &str) -> usize { + let previous = token_spans(previous); + let current = token_spans(current); + previous + .iter() + .zip(¤t) + .take_while(|((left, _, _), (right, _, _))| left == right) + .map(|(_, (_, _, end))| *end) + .last() + .unwrap_or(0) +} + +pub(super) fn token_spans(text: &str) -> Vec<(&str, usize, usize)> { + let mut tokens = Vec::new(); + let mut start = None; + for (index, character) in text + .char_indices() + .chain(std::iter::once((text.len(), ' '))) + { + match (start, character.is_whitespace()) { + (None, false) => start = Some(index), + (Some(begin), true) => { + tokens.push((&text[begin..index], begin, index)); + start = None; + } + _ => {} + } + } + tokens +} diff --git a/crates/gateway-stt/src/take/final_outcome.rs b/crates/gateway-stt/src/take/final_outcome.rs new file mode 100644 index 00000000..2e611174 --- /dev/null +++ b/crates/gateway-stt/src/take/final_outcome.rs @@ -0,0 +1,98 @@ +use std::ops::Range; + +use super::text::append_transcript; +use super::window::AcceptedHypothesis; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum SkipReason { + BelowSpeechThreshold, + BelowFinalWindow, + Silence, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) enum FinalRangeResult { + Decoded(String), + Skipped(SkipReason), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct FinalRangeOutcome { + pub(super) range: Range, + pub(super) result: FinalRangeResult, +} + +impl FinalRangeOutcome { + pub(super) fn decoded(range: Range, text: String) -> Self { + Self { + range, + result: FinalRangeResult::Decoded(text), + } + } + + pub(super) fn skipped(range: Range, reason: SkipReason) -> Self { + Self { + range, + result: FinalRangeResult::Skipped(reason), + } + } +} + +pub(super) fn assemble_completion( + outcomes: &[FinalRangeOutcome], + accepted: &[AcceptedHypothesis], + committed_samples: usize, +) -> String { + let mut transcript = String::new(); + let mut used = vec![false; accepted.len()]; + for (outcome_index, outcome) in outcomes.iter().enumerate() { + match &outcome.result { + FinalRangeResult::Decoded(text) => append_transcript(&mut transcript, text), + FinalRangeResult::Skipped(_) => { + let candidate = accepted.iter().enumerate().find(|(index, hypothesis)| { + !used[*index] + && hypothesis.range().end <= committed_samples + && skipped_outcomes_exactly_cover( + outcomes, + outcome_index, + &hypothesis.range(), + ) + }); + if let Some((index, hypothesis)) = candidate { + used[index] = true; + append_transcript(&mut transcript, hypothesis.text()); + } + } + } + } + transcript +} + +fn skipped_outcomes_exactly_cover( + outcomes: &[FinalRangeOutcome], + first: usize, + hypothesis: &Range, +) -> bool { + if outcomes[first].range.start > hypothesis.start + || outcomes[first].range.end <= hypothesis.start + { + return false; + } + let mut covered_end = hypothesis.start; + for outcome in &outcomes[first..] { + if !matches!(outcome.result, FinalRangeResult::Skipped(_)) { + return false; + } + if outcome.range.end <= covered_end { + continue; + } + if outcome.range.start > covered_end { + return false; + } + covered_end = outcome.range.end; + if covered_end >= hypothesis.end { + return true; + } + } + false +} diff --git a/crates/gateway-stt/src/take/finalization.rs b/crates/gateway-stt/src/take/finalization.rs new file mode 100644 index 00000000..540d8548 --- /dev/null +++ b/crates/gateway-stt/src/take/finalization.rs @@ -0,0 +1,379 @@ +use std::future::Future; +use std::ops::Range; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use gateway_stt_engine::{DecodeMode, DecodeRequest, EnginePolicy, TranscribeError}; +use tokio::sync::{mpsc, oneshot}; + +use crate::generation::GenerationLease; +use crate::segment::SegmentOutcome; + +use super::final_outcome::{FinalRangeOutcome, SkipReason}; +use super::state::TakeState; +use super::window::AcceptedHypothesis; + +pub(super) type TakeFinalization = Pin> + Send>>; +pub(super) const FINAL_SEGMENT_CAPACITY: usize = 4; + +#[derive(Debug)] +pub(super) enum FinalCommand { + Segment { + samples: Vec, + range: Range, + leading_silence: Option>, + }, + Skipped { + range: Range, + reason: SkipReason, + leading_silence: Option>, + }, + Complete { + tail: Vec, + start: usize, + committed_samples: usize, + accepted: Vec, + reply: oneshot::Sender>, + }, +} + +#[derive(Debug)] +pub(super) struct FinalPipeline { + commands: mpsc::Sender, + task: tokio::task::JoinHandle<()>, + pending_segments: Arc, +} + +impl Drop for FinalPipeline { + fn drop(&mut self) { + self.task.abort(); + } +} + +impl FinalPipeline { + pub(super) fn submit_closed_segments(&self, state: &TakeState) { + loop { + let outcome = { + let buffer = TakeState::lock(&state.buffer); + let mut segmenter = TakeState::lock(&state.segmenter); + let previous_consumed = segmenter.consumed(); + segmenter.poll(&buffer).map(|outcome| { + let range = match &outcome { + SegmentOutcome::Decode(range) | SegmentOutcome::Skipped(range) => range, + }; + let leading_silence = + (previous_consumed < range.start).then(|| previous_consumed..range.start); + match outcome { + SegmentOutcome::Decode(range) => FinalCommand::Segment { + samples: buffer[range.clone()].to_vec(), + range, + leading_silence, + }, + SegmentOutcome::Skipped(range) => FinalCommand::Skipped { + range, + reason: SkipReason::BelowSpeechThreshold, + leading_silence, + }, + } + }) + }; + let Some(command) = outcome else { + break; + }; + if !reserve_segment(&self.pending_segments) { + state.record_failure("final segment capacity is reached".to_owned()); + break; + } + match self.commands.try_send(command) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + self.pending_segments.fetch_sub(1, Ordering::AcqRel); + state.record_failure("final segment capacity is reached".to_owned()); + break; + } + Err(mpsc::error::TrySendError::Closed(_)) => { + self.pending_segments.fetch_sub(1, Ordering::AcqRel); + state.record_failure("final transcription pipeline exited".to_owned()); + break; + } + } + } + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(super) fn pending_segments(&self) -> usize { + self.pending_segments.load(Ordering::Acquire) + } + + pub(super) fn finalization( + &self, + tail: Vec, + start: usize, + committed_samples: usize, + accepted: Vec, + ) -> TakeFinalization { + let commands = self.commands.clone(); + Box::pin(async move { + let (reply, reply_rx) = oneshot::channel(); + if commands + .send(FinalCommand::Complete { + tail, + start, + committed_samples, + accepted, + reply, + }) + .await + .is_err() + { + return Err("final transcription pipeline exited".to_owned()); + } + reply_rx + .await + .unwrap_or_else(|_| Err("final transcription pipeline exited".to_owned())) + }) + } +} + +pub(super) fn reserve_segment(pending_segments: &AtomicUsize) -> bool { + pending_segments + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |pending| { + (pending < FINAL_SEGMENT_CAPACITY).then_some(pending + 1) + }) + .is_ok() +} + +pub(super) fn spawn_final_pipeline( + engine: GenerationLease, + guidance: Arc<[String]>, + state: Arc, +) -> FinalPipeline { + let (commands, receiver) = mpsc::channel(FINAL_SEGMENT_CAPACITY); + let pending_segments = Arc::new(AtomicUsize::new(0)); + let task = tokio::spawn(run_final_pipeline( + receiver, + guidance, + state, + Arc::clone(&pending_segments), + move |samples, guidance, finalized| { + let engine = engine.clone(); + async move { + if !engine.has_final_pass() { + return None; + } + Some( + engine + .decode(DecodeRequest::new( + DecodeMode::Final, + samples, + guidance, + finalized, + )) + .await, + ) + } + }, + )); + FinalPipeline { + commands, + task, + pending_segments, + } +} + +pub(super) async fn run_final_pipeline( + mut receiver: mpsc::Receiver, + guidance: Arc<[String]>, + state: Arc, + pending_segments: Arc, + mut decode: D, +) where + D: FnMut(Vec, Vec, String) -> F, + F: Future>>, +{ + while let Some(command) = receiver.recv().await { + match command { + FinalCommand::Segment { + samples, + range, + leading_silence, + } => { + record_leading_silence(&state, leading_silence); + process_samples(&state, &guidance, &mut decode, samples, range).await; + pending_segments.fetch_sub(1, Ordering::AcqRel); + } + FinalCommand::Skipped { + range, + reason, + leading_silence, + } => { + record_leading_silence(&state, leading_silence); + state.record_final_outcome(FinalRangeOutcome::skipped(range, reason)); + pending_segments.fetch_sub(1, Ordering::AcqRel); + } + FinalCommand::Complete { + tail, + start, + committed_samples, + accepted, + reply, + } => { + process_samples( + &state, + &guidance, + &mut decode, + tail, + start..committed_samples, + ) + .await; + drop(reply.send(state.completion(&accepted, committed_samples))); + break; + } + } + } +} + +fn record_leading_silence(state: &TakeState, range: Option>) { + if let Some(range) = range { + state.record_final_outcome(FinalRangeOutcome::skipped(range, SkipReason::Silence)); + } +} + +async fn process_samples( + state: &TakeState, + guidance: &[String], + decode: &mut D, + samples: Vec, + range: Range, +) where + D: FnMut(Vec, Vec, String) -> F, + F: Future>>, +{ + if state.has_failure() { + return; + } + let skipped = if samples.len() < EnginePolicy::MIN_WINDOW_SAMPLES { + Some(SkipReason::BelowFinalWindow) + } else if EnginePolicy::is_silence(&samples) { + Some(SkipReason::Silence) + } else { + None + }; + if let Some(reason) = skipped { + state.record_final_outcome(FinalRangeOutcome::skipped(range, reason)); + return; + } + let finalized = state.finalized(); + match decode(samples, guidance.to_vec(), finalized).await { + Some(Ok(text)) => { + state.record_final_outcome(FinalRangeOutcome::decoded(range, text)); + } + Some(Err(error)) => state.record_failure(error.to_string()), + None => { + state.record_failure("final transcription worker is unavailable".to_owned()); + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use tokio::sync::{mpsc, oneshot}; + + use super::{FinalCommand, run_final_pipeline}; + use crate::take::Take; + use crate::take::state::TakeState; + use crate::take::window::AcceptedHypothesis; + + fn accepted_from_snapshot( + take: &Take, + range: std::ops::Range, + text: &str, + committed_samples: usize, + ) -> Vec { + take.next_window_snapshot(text, range.start, range.start, range.end) + .expect("the production window snapshot is accepted"); + TakeState::lock(&take.whole_window).accepted_hypotheses(committed_samples) + } + + #[tokio::test] + async fn pipeline_reconciles_short_tail_without_decoding_it() { + let (commands, receiver) = mpsc::channel(1); + let take = Take::without_final(Vec::new()); + take.append(&vec![0.5; 4_800]); + let accepted = accepted_from_snapshot(&take, 0..4_800, "last word", 4_800); + let state = Arc::clone(&take.state); + let calls = Arc::new(AtomicUsize::new(0)); + let decode_calls = Arc::clone(&calls); + let task = tokio::spawn(run_final_pipeline( + receiver, + Arc::from([]), + state, + Arc::new(AtomicUsize::new(0)), + move |_, _, _| { + decode_calls.fetch_add(1, Ordering::SeqCst); + async { Some(Ok("must not decode".to_owned())) } + }, + )); + let (reply, completion) = oneshot::channel(); + commands + .send(FinalCommand::Complete { + tail: vec![0.5; 4_800], + start: 0, + committed_samples: 4_800, + accepted, + reply, + }) + .await + .expect("completion queues"); + + assert_eq!( + completion.await.expect("completion replies"), + Ok("last word".to_owned()) + ); + assert_eq!(calls.load(Ordering::SeqCst), 0); + task.await.expect("pipeline exits"); + } + + #[tokio::test] + async fn pipeline_rejects_a_hypothesis_with_only_partial_skipped_coverage() { + let (commands, receiver) = mpsc::channel(1); + let take = Take::without_final(Vec::new()); + take.append(&vec![0.5; 8_000]); + let accepted = accepted_from_snapshot(&take, 0..8_000, "must not inherit", 8_000); + let state = Arc::clone(&take.state); + let calls = Arc::new(AtomicUsize::new(0)); + let decode_calls = Arc::clone(&calls); + let task = tokio::spawn(run_final_pipeline( + receiver, + Arc::from([]), + state, + Arc::new(AtomicUsize::new(0)), + move |_, _, _| { + decode_calls.fetch_add(1, Ordering::SeqCst); + async { Some(Ok("must not decode".to_owned())) } + }, + )); + let (reply, completion) = oneshot::channel(); + commands + .send(FinalCommand::Complete { + tail: vec![0.5; 4_000], + start: 4_000, + committed_samples: 8_000, + accepted, + reply, + }) + .await + .expect("completion queues"); + + assert_eq!( + completion.await.expect("completion replies"), + Ok(String::new()) + ); + assert_eq!(calls.load(Ordering::SeqCst), 0); + task.await.expect("pipeline exits"); + } +} diff --git a/crates/gateway-stt/src/take/interim.rs b/crates/gateway-stt/src/take/interim.rs new file mode 100644 index 00000000..7e70e14d --- /dev/null +++ b/crates/gateway-stt/src/take/interim.rs @@ -0,0 +1,27 @@ +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct InterimSnapshot { + transcript: String, + finalized: String, + agreed: String, + tentative: String, +} + +impl InterimSnapshot { + pub(super) fn new(finalized: String, agreed: String, tentative: String) -> Self { + let transcript = format!("{finalized}{agreed}{tentative}"); + Self { + transcript, + finalized, + agreed, + tentative, + } + } + + pub(crate) fn committed(&self) -> &str { + &self.transcript[..self.finalized.len() + self.agreed.len()] + } + + pub(crate) fn into_parts(self) -> (String, String, String, String) { + (self.transcript, self.finalized, self.agreed, self.tentative) + } +} diff --git a/crates/gateway-stt/src/take/state.rs b/crates/gateway-stt/src/take/state.rs new file mode 100644 index 00000000..3fd7f239 --- /dev/null +++ b/crates/gateway-stt/src/take/state.rs @@ -0,0 +1,157 @@ +use std::sync::{Mutex, MutexGuard, PoisonError}; + +#[cfg(test)] +use gateway_stt_engine::TranscribeError; + +use super::final_outcome::{FinalRangeOutcome, FinalRangeResult, assemble_completion}; +use super::text::append_transcript; +use super::window::AcceptedHypothesis; +use crate::segment::Segmenter; + +#[derive(Debug, Default)] +struct FinalizedState { + text: String, + failure: Option, + samples: usize, + outcomes: Vec, + has_skipped_coverage: bool, +} + +#[derive(Debug, Default)] +pub(super) struct TakeState { + pub(super) buffer: Mutex>, + pub(super) segmenter: Mutex, + finalized: Mutex, +} + +impl TakeState { + pub(super) fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) + } + + pub(super) fn finalized(&self) -> String { + Self::lock(&self.finalized).text.clone() + } + + pub(super) fn finalized_snapshot(&self) -> (String, usize) { + self.finalized_snapshot_with(|| {}) + } + + fn finalized_snapshot_with(&self, synchronized: impl FnOnce()) -> (String, usize) { + let state = Self::lock(&self.finalized); + synchronized(); + (state.text.clone(), state.samples) + } + + #[cfg(test)] + pub(super) fn record_finalized( + &self, + result: Result, + samples: Option, + ) { + let mut state = Self::lock(&self.finalized); + match result { + Ok(text) if state.failure.is_none() => { + append_transcript(&mut state.text, &text); + if let Some(samples) = samples { + state.samples = samples; + } + } + Err(error) if state.failure.is_none() => state.failure = Some(error.to_string()), + Ok(_) | Err(_) => {} + } + } + + pub(super) fn record_final_outcome(&self, outcome: FinalRangeOutcome) { + let mut state = Self::lock(&self.finalized); + if state.failure.is_some() { + return; + } + match &outcome.result { + FinalRangeResult::Decoded(text) => { + if !state.has_skipped_coverage { + append_transcript(&mut state.text, text); + state.samples = outcome.range.end; + } + } + FinalRangeResult::Skipped(_) => { + state.has_skipped_coverage = true; + } + } + state.outcomes.push(outcome); + } + + pub(super) fn record_failure(&self, failure: String) { + let mut state = Self::lock(&self.finalized); + if state.failure.is_none() { + state.failure = Some(failure); + } + } + + pub(super) fn has_failure(&self) -> bool { + Self::lock(&self.finalized).failure.is_some() + } + + pub(super) fn pending_failure(&self) -> Option { + Self::lock(&self.finalized).failure.clone() + } + + #[cfg(test)] + pub(super) fn take_failure(&self) -> Option { + Self::lock(&self.finalized).failure.take() + } + + pub(super) fn completion( + &self, + accepted: &[AcceptedHypothesis], + committed_samples: usize, + ) -> Result { + let mut state = Self::lock(&self.finalized); + match state.failure.take() { + Some(failure) => Err(failure), + None if state.outcomes.is_empty() => Ok(state.text.clone()), + None => { + let transcript = assemble_completion(&state.outcomes, accepted, committed_samples); + state.samples = committed_samples; + Ok(transcript) + } + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::mpsc; + use std::time::Duration; + + use gateway_stt_engine::TranscribeError; + + use super::TakeState; + + #[test] + fn finalized_snapshot_cannot_mix_text_and_sample_ownership() { + let state = Arc::new(TakeState::default()); + state.record_finalized(Ok::<_, TranscribeError>("old".to_owned()), Some(100)); + let writer_state = Arc::clone(&state); + let (start, started) = mpsc::channel(); + let writer = std::thread::spawn(move || { + start.send(()).expect("snapshot knows the writer is ready"); + writer_state.record_finalized(Ok::<_, TranscribeError>("new".to_owned()), Some(200)); + }); + + let snapshot = state.finalized_snapshot_with(|| { + started + .recv_timeout(Duration::from_secs(1)) + .expect("writer reaches the synchronized snapshot boundary"); + assert!( + state.finalized.try_lock().is_err(), + "the text and sample watermark share one held lock" + ); + }); + writer.join().expect("finalization writer joins"); + + assert_eq!(snapshot, ("old".to_owned(), 100)); + assert_eq!(state.finalized_snapshot(), ("old new".to_owned(), 200)); + } +} diff --git a/crates/gateway-stt/src/take/text.rs b/crates/gateway-stt/src/take/text.rs new file mode 100644 index 00000000..62635919 --- /dev/null +++ b/crates/gateway-stt/src/take/text.rs @@ -0,0 +1,9 @@ +pub(super) fn append_transcript(text: &mut String, piece: &str) { + if piece.is_empty() { + return; + } + if !text.is_empty() { + text.push(' '); + } + text.push_str(piece); +} diff --git a/crates/gateway-stt/src/take/window.rs b/crates/gateway-stt/src/take/window.rs new file mode 100644 index 00000000..492cb595 --- /dev/null +++ b/crates/gateway-stt/src/take/window.rs @@ -0,0 +1,250 @@ +use std::ops::Range; + +use super::agreement::{matching_token_prefix_end, token_spans}; +use super::interim::InterimSnapshot; +use super::text::append_transcript; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct AcceptedHypothesis { + range: Range, + text: String, +} + +impl AcceptedHypothesis { + pub(super) fn new(range: Range, text: String) -> Self { + Self { range, text } + } + + pub(super) fn range(&self) -> Range { + self.range.clone() + } + + pub(super) fn text(&self) -> &str { + &self.text + } +} + +#[derive(Debug)] +struct PendingRegion { + end: usize, + accepted: AcceptedHypothesis, +} + +#[derive(Debug, Default)] +pub(super) struct WholeWindowState { + segment_start: usize, + window_start: Option, + active: String, + active_end: usize, + pending: Vec, + last: Option, +} + +impl WholeWindowState { + pub(super) fn next( + &mut self, + finalized: &str, + finalized_samples: usize, + segment_start: usize, + window_start: usize, + window_end: usize, + hypothesis: &str, + ) -> Option { + self.pending.retain(|region| region.end > finalized_samples); + if self.window_start.is_some() && self.segment_start != segment_start { + if !self.active.is_empty() { + self.pending.push(PendingRegion { + end: segment_start, + accepted: AcceptedHypothesis::new( + self.segment_start..self.active_end, + std::mem::take(&mut self.active), + ), + }); + } + self.window_start = None; + } + self.segment_start = segment_start; + + let replacement = match self.window_start { + Some(previous_start) if window_start > previous_start => { + rebase_sliding_window(&self.active, hypothesis) + } + Some(_) | None => hypothesis.to_owned(), + }; + let agreed_end = if self.active.is_empty() { + 0 + } else { + matching_token_prefix_end(&self.active, &replacement) + }; + self.active = replacement; + self.active_end = window_end; + self.window_start = Some(window_start); + + let mut agreed = String::new(); + for region in &self.pending { + append_transcript(&mut agreed, region.accepted.text()); + } + append_transcript(&mut agreed, self.active[..agreed_end].trim()); + let agreed = owned_piece(!finalized.is_empty(), &agreed); + let tentative = owned_piece( + !finalized.is_empty() || !agreed.is_empty(), + &self.active[agreed_end..], + ); + let snapshot = InterimSnapshot::new(finalized.to_owned(), agreed, tentative); + if self.last.as_ref() == Some(&snapshot) { + return (!hypothesis.is_empty()).then_some(snapshot); + } + self.last = Some(snapshot.clone()); + Some(snapshot) + } + + pub(super) fn accepted_hypotheses(&self, committed_samples: usize) -> Vec { + let mut accepted = self + .pending + .iter() + .map(|region| region.accepted.clone()) + .filter(|hypothesis| hypothesis.range.end <= committed_samples) + .collect::>(); + if !self.active.is_empty() && self.active_end <= committed_samples { + accepted.push(AcceptedHypothesis::new( + self.segment_start..self.active_end, + self.active.clone(), + )); + } + accepted + } +} + +fn rebase_sliding_window(previous: &str, current: &str) -> String { + let previous_tokens = token_spans(previous); + let current_tokens = token_spans(current); + for overlap in (1..=previous_tokens.len().min(current_tokens.len())).rev() { + let previous_start = previous_tokens.len() - overlap; + if previous_tokens[previous_start..] + .iter() + .map(|(token, _, _)| *token) + .zip(current_tokens[..overlap].iter().map(|(token, _, _)| *token)) + .all(|(previous, current)| equivalent_token(previous, current)) + { + let mut rebased = previous[..previous_tokens[previous_start].1] + .trim_end() + .to_owned(); + append_transcript(&mut rebased, current); + return rebased; + } + } + current.to_owned() +} + +fn equivalent_token(left: &str, right: &str) -> bool { + left.chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .eq(right + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase)) +} + +fn owned_piece(has_prefix: bool, piece: &str) -> String { + if !has_prefix || piece.is_empty() || piece.starts_with(char::is_whitespace) { + piece.to_owned() + } else { + format!(" {piece}") + } +} + +#[cfg(test)] +mod tests { + use super::WholeWindowState; + + #[test] + fn whole_window_revision_replaces_a_promoted_leading_phrase() { + let mut state = WholeWindowState::default(); + state.next("", 0, 0, 0, 8_000, "Why is it"); + state.next("", 0, 0, 0, 9_600, "Why is it"); + let snapshot = state + .next("", 0, 0, 0, 11_200, "Why is this") + .expect("a revised whole-window hypothesis emits"); + + assert_eq!( + snapshot.into_parts(), + ( + "Why is this".to_owned(), + String::new(), + "Why is".to_owned(), + " this".to_owned(), + ) + ); + } + + #[test] + fn consumed_boundary_starts_a_region_before_finalization_arrives() { + let mut state = WholeWindowState::default(); + state.next("", 0, 0, 0, 16_000, "first segment"); + state.next("", 0, 0, 0, 16_000, "first segment"); + let pending = state + .next("", 0, 16_000, 16_000, 24_000, "second start") + .expect("the new segment starts without waiting for final text"); + assert_eq!(pending.into_parts().0, "first segment second start"); + + let authoritative = state + .next( + "revised first", + 16_000, + 16_000, + 16_000, + 25_600, + "second start now", + ) + .expect("authoritative text replaces the pending segment"); + assert_eq!( + authoritative.into_parts().0, + "revised first second start now" + ); + } + + #[test] + fn advancing_window_rebases_through_overlap_without_repeating_it() { + let mut state = WholeWindowState::default(); + state.next("", 0, 0, 0, 16_000, "ask not what your country can do"); + let snapshot = state + .next("", 0, 0, 8_000, 24_000, "your country can do for you") + .expect("the sliding window emits a rebased hypothesis"); + + assert_eq!( + snapshot.into_parts().0, + "ask not what your country can do for you" + ); + } + + #[test] + fn sliding_overlap_tolerates_native_punctuation_revision() { + let mut state = WholeWindowState::default(); + state.next("", 0, 0, 0, 64_000, "And so my fellow Americans, ask"); + let snapshot = state + .next("", 0, 0, 16_000, 80_000, "my fellow Americans ask not") + .expect("the punctuated native overlap rebases"); + + assert_eq!( + snapshot.into_parts().0, + "And so my fellow Americans ask not" + ); + } + + #[test] + fn accepted_hypothesis_retains_exact_committed_audio_coverage() { + let mut state = WholeWindowState::default(); + state.next("", 0, 32_000, 32_000, 40_000, "old"); + state.next("", 0, 32_000, 32_000, 44_800, "last word"); + + assert!( + state.accepted_hypotheses(40_000).is_empty(), + "a snapshot extending beyond committed audio is not reusable" + ); + let accepted = state.accepted_hypotheses(44_800); + assert_eq!(accepted.len(), 1); + assert_eq!(accepted[0].range(), 32_000..44_800); + assert_eq!(accepted[0].text(), "last word"); + } +} diff --git a/crates/gateway-stt/src/test_fixtures.rs b/crates/gateway-stt/src/test_fixtures.rs new file mode 100644 index 00000000..485a8bb7 --- /dev/null +++ b/crates/gateway-stt/src/test_fixtures.rs @@ -0,0 +1,442 @@ +//! Deterministic fixtures split by service responsibility. + +#[cfg(feature = "test-fixtures")] +use std::future::Future; + +#[cfg(feature = "test-fixtures")] +use crate::realtime::{CommitReceipt, ItemResult, Session, SessionRegistry}; + +#[cfg(feature = "test-fixtures")] +mod generation; +#[cfg(all(test, not(miri)))] +mod native; +#[cfg(feature = "test-fixtures")] +mod segment; + +#[cfg(feature = "test-fixtures")] +pub use gateway_stt_engine::DecodeMode; +#[cfg(feature = "test-fixtures")] +pub use gateway_stt_engine::test_fixtures::{ScriptedDecoder, ScriptedModelFactory}; +#[cfg(feature = "test-fixtures")] +pub use generation::{ + GenerationOwnershipFixture, GenerationWorkerJobFixture, begin_scripted_replacement, + generation_counts, generation_ownership, scripted_service, +}; +#[cfg(all(test, not(miri)))] +pub(crate) use native::{jfk_samples, require_model}; +#[cfg(feature = "test-fixtures")] +pub use segment::segment_ranges; + +/// A deterministic registry for focused Realtime session integration tests. +#[cfg(feature = "test-fixtures")] +#[derive(Clone, Debug, Default)] +pub struct RealtimeSessionRegistryFixture { + inner: SessionRegistry, +} + +#[cfg(feature = "test-fixtures")] +impl RealtimeSessionRegistryFixture { + /// Registers one session immediately. + /// + /// # Errors + /// Returns the stable capacity error when eight sessions are active or retiring. + pub fn register(&self) -> Result { + let registration = self.inner.register().map_err(|error| error.to_string())?; + Ok(RealtimeSessionFixture { + session: Session::new(registration, None), + }) + } + + /// Registers one session backed by deterministic scripted workers. + /// + /// # Errors + /// Returns a stable registration, policy, or worker startup error. + pub fn register_with_scripted_engine( + &self, + factory: ScriptedModelFactory, + ) -> Result { + let registration = self.inner.register().map_err(|error| error.to_string())?; + let service = scripted_service(factory, 15, 500).map_err(|error| error.to_string())?; + let engine = service + .state + .active() + .ok_or_else(|| "scripted generation did not publish".to_owned())?; + Ok(RealtimeSessionFixture { + session: Session::new(registration, Some(engine)), + }) + } + + /// Returns active and still-retiring session ownership. + #[must_use] + pub fn active(&self) -> usize { + self.inner.active() + } + + /// Returns the number of registry cleanup events emitted. + #[must_use] + pub fn cleanup_event_count(&self) -> usize { + self.inner.cleanup_event_count() + } + + /// Returns the number of retired tasks whose joins failed after cancellation. + #[must_use] + pub fn retired_task_failures(&self) -> usize { + self.inner.retired_task_failures() + } + + /// Waits for the next registry-owned session cleanup. + pub fn cleanup_notified(&self) -> impl Future + '_ { + self.inner.cleanup_notified() + } +} + +/// The immutable first-append configuration captured by a fixture session. +#[cfg(feature = "test-fixtures")] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RealtimeInputSnapshotFixture { + item_id: String, + prompt: String, + include_hypothesis: bool, +} + +/// The IDs established by one successful fixture commit. +#[cfg(feature = "test-fixtures")] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RealtimeCommitFixture(CommitReceipt); + +#[cfg(feature = "test-fixtures")] +impl RealtimeCommitFixture { + /// Returns the promoted provisional item ID. + #[must_use] + pub fn item_id(&self) -> &str { + self.0.item_id() + } + + /// Returns the preceding durable committed-item ID. + #[must_use] + pub fn previous_item_id(&self) -> Option<&str> { + self.0.previous_item_id() + } +} + +#[cfg(feature = "test-fixtures")] +impl RealtimeInputSnapshotFixture { + /// Returns the provisional item ID. + #[must_use] + pub fn item_id(&self) -> &str { + &self.item_id + } + + /// Returns the captured prompt. + #[must_use] + pub fn prompt(&self) -> &str { + &self.prompt + } + + /// Reports whether hypothesis snapshots were negotiated. + #[must_use] + pub const fn include_hypothesis(&self) -> bool { + self.include_hypothesis + } +} + +/// A deterministic Realtime session surface for focused integration tests. +#[cfg(feature = "test-fixtures")] +#[derive(Debug)] +pub struct RealtimeSessionFixture { + session: Session, +} + +#[cfg(feature = "test-fixtures")] +impl RealtimeSessionFixture { + /// Applies one client session update. + /// + /// # Errors + /// Returns the wire validation error for an invalid update. + pub fn update_text(&mut self, text: &str) -> Result<(), String> { + self.session + .update_text(text) + .map_err(|error| format!("{error:?}")) + } + + /// Appends one Base64-encoded PCM16 chunk. + /// + /// # Errors + /// Returns the audio or session ownership error. + pub fn append_base64(&mut self, payload: &str) -> Result<(), String> { + self.session + .append_base64(payload) + .map_err(|error| error.to_string()) + } + + /// Clears uncommitted input and retires its current interim task. + /// + /// # Errors + /// Returns the bounded cleanup or epoch error. + pub fn clear(&mut self) -> Result<(), String> { + self.session.clear().map_err(|error| error.to_string()) + } + + /// Returns the current immutable input snapshot. + pub fn input_snapshot(&self) -> Option { + self.session + .input() + .map(|input| RealtimeInputSnapshotFixture { + item_id: input.item_id().to_owned(), + prompt: input.snapshot().prompt().to_owned(), + include_hypothesis: input.snapshot().include_hypothesis(), + }) + } + + /// Returns the current input's resampled audio snapshot. + pub fn resampled_audio(&self) -> Option> { + self.session + .input() + .map(|input| input.take().uncommitted_snapshot(usize::MAX)) + } + + /// Commits the current input after reserving all item capacities. + /// + /// # Errors + /// Returns validation or bounded-capacity failures without detaching input. + pub fn commit(&mut self) -> Result { + self.session + .commit() + .map(RealtimeCommitFixture) + .map_err(|error| error.to_string()) + } + + /// Records a final-segment failure before commit. + /// + /// # Errors + /// Returns an error when there is no uncommitted input. + pub fn fail_precommit(&mut self, failure: &str) -> Result<(), String> { + self.session + .record_pending_failure(failure.to_owned()) + .map_err(|error| error.to_string()) + } + + /// Adds one accepted nonterminal result to bounded session capacity. + /// + /// # Errors + /// Returns item-state or capacity errors. + pub fn push_delta(&mut self, item_id: &str, transcript: &str) -> Result<(), String> { + self.session + .push_delta(item_id, transcript.to_owned()) + .map_err(|error| error.to_string()) + } + + /// Replaces the item's newest-wins hypothesis slot. + /// + /// # Errors + /// Returns item-state errors. + pub fn replace_hypothesis( + &mut self, + item_id: &str, + revision: u64, + transcript: &str, + ) -> Result<(), String> { + self.session + .replace_hypothesis(item_id, revision, transcript.to_owned()) + .map_err(|error| error.to_string()) + } + + /// Records the item's sole successful terminal outcome. + /// + /// # Errors + /// Returns item-state errors, including duplicate terminal attempts. + pub fn finalize_completed(&mut self, item_id: &str, transcript: &str) -> Result<(), String> { + self.session + .finalize_completed(item_id, transcript.to_owned()) + .map_err(|error| error.to_string()) + } + + /// Records the item's sole failed terminal outcome. + /// + /// # Errors + /// Returns item-state errors, including duplicate terminal attempts. + pub fn finalize_failed(&mut self, item_id: &str, message: &str) -> Result<(), String> { + self.session + .finalize_failed(item_id, message.to_owned()) + .map_err(|error| error.to_string()) + } + + /// Drains bounded results and releases terminal item ownership. + pub fn drain_results(&mut self) -> Vec { + self.session + .drain_results() + .into_iter() + .map(result_value) + .collect() + } + + /// Returns committed items, including terminal events awaiting drain. + #[must_use] + pub fn committed_count(&self) -> usize { + self.session.committed_count() + } + + /// Returns committed items with active accurate finalization tasks. + #[must_use] + pub fn finalizing_count(&self) -> usize { + self.session.finalizing_count() + } + + /// Replaces one committed item's accurate finalization with controlled work. + /// + /// # Errors + /// Returns an error when the committed item does not exist. + pub fn replace_finalization(&mut self, item_id: &str, task: F) -> Result<(), String> + where + F: Future> + Send + 'static, + { + self.session + .replace_finalization(item_id, task) + .map_err(|error| error.to_string()) + } + + /// Returns the committed immutable prompt and take guidance. + pub fn committed_prompt_and_guidance(&self, item_id: &str) -> Option<(String, Vec)> { + self.session + .committed_prompt_and_guidance(item_id) + .map(|(prompt, guidance)| (prompt.to_owned(), guidance.to_vec())) + } + + /// Returns the sole take-owned pending precommit failure. + pub fn pending_failure(&self) -> Option { + self.session.pending_failure() + } + + /// Returns final segments admitted by the current take but not yet processed. + pub fn pending_final_segments(&self) -> Option { + self.session.pending_final_segments() + } + + /// Awaits one item's independently owned accurate finalization. + /// + /// # Errors + /// Returns item-state, task, decode, or result-mailbox errors. + pub async fn finish_finalization(&mut self, item_id: &str) -> Result<(), String> { + self.session + .finish_finalization(item_id) + .await + .map_err(|error| error.to_string()) + } + + /// Spawns one session-owned interim task. + /// + /// # Errors + /// Returns the bounded cleanup or epoch error. + pub fn spawn_interim(&mut self, task: F) -> Result<(), String> + where + F: Future + Send + 'static, + { + self.session + .spawn_interim(task) + .map(|_| ()) + .map_err(|error| error.to_string()) + } + + /// Accepts one interim, clears its input, then rejects the stale epoch. + /// + /// # Errors + /// Returns an ownership, cleanup, epoch, or serialization error. + pub fn accept_interim_across_clear( + &mut self, + current: &str, + stale: &str, + ) -> Result<(Option, Option), String> { + let epoch = self + .session + .begin_interim() + .map_err(|error| error.to_string())?; + let current = self + .session + .accept_interim(epoch, current.to_owned()) + .map(serde_json::to_value) + .transpose() + .map_err(|error| error.to_string())?; + self.session.clear().map_err(|error| error.to_string())?; + let stale = self + .session + .accept_interim(epoch, stale.to_owned()) + .map(serde_json::to_value) + .transpose() + .map_err(|error| error.to_string())?; + Ok((current, stale)) + } + + /// Awaits and accepts the current interim task without relinquishing ownership. + /// + /// # Errors + /// Returns a task, session, or serialization error. + pub async fn finish_interim(&mut self) -> Result, String> { + self.session + .finish_interim() + .await + .map_err(|error| error.to_string())? + .map(serde_json::to_value) + .transpose() + .map_err(|error| error.to_string()) + } + + /// Joins every canceled interim task without relinquishing ownership. + /// + /// # Errors + /// Returns an error when a canceled task failed instead of canceling. + pub async fn join_canceled(&mut self) -> Result<(), String> { + self.session + .join_canceled() + .await + .map_err(|error| error.to_string()) + } + + /// Returns the number of retained canceled-task joins. + pub const fn canceled_join_count(&self) -> usize { + self.session.canceled_join_count() + } + + /// Returns the number of allocated server event IDs. + pub fn allocated_event_count(&self) -> u64 { + self.session.allocated_event_count() + } +} + +#[cfg(feature = "test-fixtures")] +fn result_value(result: ItemResult) -> serde_json::Value { + match result { + ItemResult::Delta { + item_id, + transcript, + } => serde_json::json!({ + "type": "delta", + "item_id": item_id, + "transcript": transcript, + }), + ItemResult::Hypothesis { + item_id, + revision, + transcript, + } => serde_json::json!({ + "type": "hypothesis", + "item_id": item_id, + "revision": revision, + "transcript": transcript, + }), + ItemResult::Completed { + item_id, + transcript, + seconds, + } => serde_json::json!({ + "type": "completed", + "item_id": item_id, + "transcript": transcript, + "seconds": seconds, + }), + ItemResult::Failed { item_id, failure } => serde_json::json!({ + "type": "failed", + "item_id": item_id, + "message": failure.diagnostic(), + }), + } +} diff --git a/crates/gateway-stt/src/test_fixtures/generation.rs b/crates/gateway-stt/src/test_fixtures/generation.rs new file mode 100644 index 00000000..0df0775d --- /dev/null +++ b/crates/gateway-stt/src/test_fixtures/generation.rs @@ -0,0 +1,100 @@ +//! Deterministic generation lifecycle fixtures. + +use std::time::Duration; + +use crate::generation::{GenerationJob, GenerationLease}; +use crate::{SpeechError, SpeechService}; + +use super::ScriptedModelFactory; + +/// Builds a speech service around deterministic scripted workers. +/// +/// # Errors +/// Returns engine policy, startup, or worker construction failures. +pub fn scripted_service( + factory: ScriptedModelFactory, + window_seconds: u64, + interval_ms: u64, +) -> Result { + let gpu_available = factory.gpu_available(); + let service = SpeechService::new(); + let policy = gateway_stt_engine::EnginePolicy::new(window_seconds, interval_ms, gpu_available) + .map_err(SpeechError::Engine)?; + let replacement = service.state.stage_scripted_with_policy(factory, policy)?; + service.commit_replacement(replacement)?; + Ok(service) +} + +/// Quiesces the current generation and builds one deterministic replacement. +/// +/// # Errors +/// Returns a quiescence, policy, startup, or replacement-ownership failure. +pub fn begin_scripted_replacement( + service: &SpeechService, + factory: ScriptedModelFactory, + with_final: bool, + timeout: Duration, +) -> Result { + let gpu_available = factory.gpu_available(); + service.state.stage_scripted( + factory, + with_final.then(|| "scripted-final".to_owned()), + gpu_available, + timeout, + ) +} + +/// Returns explicit request and worker-job ownership for the active generation. +#[must_use] +pub fn generation_counts(service: &SpeechService) -> Option<(usize, usize)> { + service.state.counts() +} + +/// Admits one deterministic request owner from the current generation. +#[must_use] +pub fn generation_ownership(service: &SpeechService) -> Option { + service + .state + .active() + .map(|lease| GenerationOwnershipFixture { lease }) +} + +/// An admitted generation request exposed only to integration tests. +#[derive(Debug)] +pub struct GenerationOwnershipFixture { + lease: GenerationLease, +} + +impl GenerationOwnershipFixture { + /// Returns the replaceable session epoch captured at admission. + #[must_use] + pub fn epoch(&self) -> u64 { + self.lease.epoch().id() + } + + /// Whether replacement or shutdown canceled this request's epoch. + #[must_use] + pub fn is_replaced(&self) -> bool { + self.lease.epoch().is_cancelled() + } + + /// Adds one worker-job owner tied to this admitted request. + #[must_use] + pub fn own_worker_job(&self) -> Option { + self.lease + .own_job() + .map(|job| GenerationWorkerJobFixture { job: Some(job) }) + } +} + +/// Explicit worker ownership exposed only to integration tests. +#[derive(Debug)] +pub struct GenerationWorkerJobFixture { + job: Option, +} + +impl Drop for GenerationWorkerJobFixture { + fn drop(&mut self) { + drop(self.job.take()); + } +} diff --git a/crates/gateway-stt/src/test_fixtures/native.rs b/crates/gateway-stt/src/test_fixtures/native.rs new file mode 100644 index 00000000..d46e7a15 --- /dev/null +++ b/crates/gateway-stt/src/test_fixtures/native.rs @@ -0,0 +1,47 @@ +//! Native fixture loading for ignored crate tests. + +#![expect( + clippy::expect_used, + reason = "test fixture loading fails immediately when required native assets are invalid" +)] + +use std::path::{Path, PathBuf}; + +use gateway_stt_engine::test_fixtures::native::require_fixture; + +pub(crate) fn require_model() -> PathBuf { + require_fixture( + "PROMPTFORGE_WHISPER_MODEL", + &native_fixture_root(), + "ggml-tiny.en.bin", + ) +} + +pub(crate) fn jfk_samples() -> Vec { + let path = require_fixture( + "PROMPTFORGE_WHISPER_AUDIO", + &native_fixture_root(), + "jfk.wav", + ); + let mut reader = hound::WavReader::open(path).expect("JFK fixture opens"); + let spec = reader.spec(); + assert_eq!(spec.sample_rate, 16_000, "fixture must be 16 kHz"); + assert_eq!(spec.channels, 1, "fixture must be mono"); + assert_eq!(spec.bits_per_sample, 16, "fixture must be 16-bit PCM"); + reader + .samples::() + .map(|sample| f32::from(sample.expect("fixture sample decodes")) / 32_768.0) + .collect() +} + +fn native_fixture_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../gateway-stt-backend-whisper/tests/fixtures") +} + +#[test] +fn native_service_fixtures_keep_the_backend_fixture_root() { + assert_eq!( + native_fixture_root(), + Path::new(env!("CARGO_MANIFEST_DIR")).join("../gateway-stt-backend-whisper/tests/fixtures") + ); +} diff --git a/crates/gateway-stt/src/test_fixtures/segment.rs b/crates/gateway-stt/src/test_fixtures/segment.rs new file mode 100644 index 00000000..3cf2dd61 --- /dev/null +++ b/crates/gateway-stt/src/test_fixtures/segment.rs @@ -0,0 +1,14 @@ +//! Deterministic segmentation fixtures. + +/// Returns every closed speech range produced by the service segmenter. +#[must_use] +pub fn segment_ranges(samples: &[f32]) -> Vec> { + let mut segmenter = crate::segment::Segmenter::new(); + let mut ranges = Vec::new(); + while let Some(outcome) = segmenter.poll(samples) { + if let crate::segment::SegmentOutcome::Decode(range) = outcome { + ranges.push(range); + } + } + ranges +} diff --git a/crates/gateway-stt/tests/common/mod.rs b/crates/gateway-stt/tests/common/mod.rs index f637437b..bbaba98a 100644 --- a/crates/gateway-stt/tests/common/mod.rs +++ b/crates/gateway-stt/tests/common/mod.rs @@ -5,120 +5,208 @@ reason = "test helpers fail by panicking with the invariant named" )] -use std::time::Duration; +use std::path::{Path, PathBuf}; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use gateway_stt::SpeechService; +use gateway_stt_engine::test_fixtures::native::require_fixture; +use tower::ServiceExt as _; + +pub(crate) fn require_model() -> PathBuf { + require_fixture( + "PROMPTFORGE_WHISPER_MODEL", + &native_fixture_root(), + "ggml-tiny.en.bin", + ) +} -use futures_util::{SinkExt, StreamExt}; -use gateway_stt::{SttRuntime, SttState}; -use tokio::net::TcpStream; -use tokio_tungstenite::tungstenite::Message; -use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; +pub(crate) fn jfk_samples() -> Vec { + let path = require_fixture( + "PROMPTFORGE_WHISPER_AUDIO", + &native_fixture_root(), + "jfk.wav", + ); + let mut reader = hound::WavReader::open(path).expect("JFK fixture opens"); + let spec = reader.spec(); + assert_eq!(spec.sample_rate, 16_000, "fixture must be 16 kHz"); + assert_eq!(spec.channels, 1, "fixture must be mono"); + assert_eq!(spec.bits_per_sample, 16, "fixture must be 16-bit PCM"); + reader + .samples::() + .map(|sample| f32::from(sample.expect("fixture sample decodes")) / 32_768.0) + .collect() +} -pub(crate) const RECV_TIMEOUT: Duration = Duration::from_secs(10); +fn native_fixture_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../gateway-stt-backend-whisper/tests/fixtures") +} -pub(crate) struct TestServer { - url: String, - task: tokio::task::JoinHandle<()>, - _runtime: Option, +#[test] +fn native_integration_helpers_keep_the_backend_fixture_root() { + assert_eq!( + native_fixture_root(), + Path::new(env!("CARGO_MANIFEST_DIR")).join("../gateway-stt-backend-whisper/tests/fixtures") + ); } -impl TestServer { - pub(crate) fn spawn() -> Self { - Self::spawn_with(SttState::default(), None) - } +pub(crate) fn fixture_service(with_final: bool) -> SpeechService { + let source = require_model(); + fixture_service_with_models(&source, with_final.then_some(source.as_path())) +} - pub(crate) fn spawn_with(state: SttState, runtime: Option) -> Self { - let std_listener = - std::net::TcpListener::bind("127.0.0.1:0").expect("gateway listener binds"); - std_listener - .set_nonblocking(true) - .expect("gateway listener becomes nonblocking"); - let address = std_listener - .local_addr() - .expect("gateway listener has an address"); - let listener = - tokio::net::TcpListener::from_std(std_listener).expect("tokio adopts the listener"); - let app = gateway_stt::gateway_routes(state); - let task = tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("gateway STT fixture serves"); - }); - Self { - url: format!("http://{address}"), - task, - _runtime: runtime, - } - } +pub(crate) fn fixture_service_with_models( + interim_model: &Path, + final_model: Option<&Path>, +) -> SpeechService { + let interim_model = interim_model.to_path_buf(); + let final_model = final_model.map(Path::to_path_buf); + std::thread::spawn(move || { + fixture_service_with_models_on_dedicated_thread(&interim_model, final_model.as_deref()) + }) + .join() + .expect("fixture service startup thread succeeds") +} - pub(crate) fn ws_url(&self, path: &str) -> String { +fn fixture_service_with_models_on_dedicated_thread( + interim_model: &Path, + final_model: Option<&Path>, +) -> SpeechService { + let cache = tempfile::tempdir().expect("cache tempdir"); + let interim_source = interim_model.display().to_string().replace('\\', "/"); + let final_source = final_model.map(|path| path.display().to_string().replace('\\', "/")); + let cache_path = cache.path().display().to_string().replace('\\', "/"); + let final_model = if let Some(source) = final_source { format!( - "ws{}{}", - self.url.strip_prefix("http").expect("server URL is http"), - path + "[[stt_model]]\nname = \"speech-final\"\nrole = \"final\"\nsource = {source:?}\nvram_gb = 1.0\n" ) - } + } else { + String::new() + }; + let profile_models = if final_model.is_empty() { + "[\"speech\"]" + } else { + "[\"speech\", \"speech-final\"]" + }; + let catalog = gateway_config::Config::from_toml_str(&format!( + "config-version = 2\n\ + [server]\nbind = \"127.0.0.1:0\"\napi_key = \"k\"\n\ + [local]\ncache_dir = {cache_path:?}\n\ + [stt]\nwindow_seconds = 8\ninterval_ms = 400\n\ + [[stt_model]]\nname = \"speech\"\nrole = \"interim\"\nsource = {interim_source:?}\nvram_gb = 1.0\n\ + {final_model}[[profile]]\nname = \"work\"\nmodels = {profile_models}\n" + )) + .expect("fixture catalog parses"); + let config = catalog + .select_profile(&gateway_config::ProfileName::parse("work").expect("profile name")) + .expect("fixture profile selects"); + let service = SpeechService::new(); + let prepared = service + .prepare(&config, None) + .expect("fixture artifacts prepare"); + let replacement = service + .begin_replacement(prepared) + .expect("fixture engine loads"); + service + .commit_replacement(replacement) + .expect("fixture generation publishes"); + service } -impl Drop for TestServer { - fn drop(&mut self) { - self.task.abort(); +pub(crate) fn copy_model_replacing_token( + source: &Path, + destination_dir: &Path, + from: &[u8], + to: &[u8], +) -> PathBuf { + assert_eq!( + from.len(), + to.len(), + "model token replacement preserves size" + ); + let mut model = std::fs::read(source).expect("source model reads"); + let mut replacements = 0usize; + for offset in 0..=model.len().saturating_sub(from.len()) { + if model[offset..].starts_with(from) { + model[offset..offset + from.len()].copy_from_slice(to); + replacements += 1; + } } + assert!( + replacements > 0, + "source model vocabulary contains {:?}", + String::from_utf8_lossy(from) + ); + let destination = destination_dir.join("distinct-final-model.bin"); + std::fs::write(&destination, model).expect("distinct final model writes"); + destination } -pub(crate) struct JsonSocket { - socket: WebSocketStream>, -} - -impl JsonSocket { - pub(crate) async fn connect(url: &str) -> Self { - let (socket, _) = tokio_tungstenite::connect_async(url) - .await - .expect("WebSocket connects"); - Self { socket } - } - - pub(crate) async fn send_text(&mut self, text: &str) { - self.socket - .send(Message::Text(text.to_owned().into())) - .await - .expect("text frame sends"); - } - - pub(crate) async fn send_binary(&mut self, bytes: Vec) { - self.socket - .send(Message::Binary(bytes.into())) - .await - .expect("binary frame sends"); +fn wav_f32(samples: &[f32]) -> Vec { + let mut bytes = std::io::Cursor::new(Vec::new()); + { + let mut writer = hound::WavWriter::new( + &mut bytes, + hound::WavSpec { + channels: 1, + sample_rate: 16_000, + bits_per_sample: 32, + sample_format: hound::SampleFormat::Float, + }, + ) + .expect("WAV writer builds"); + for sample in samples { + writer.write_sample(*sample).expect("WAV sample writes"); + } + writer.finalize().expect("WAV finalizes"); } + bytes.into_inner() +} - pub(crate) async fn recv_json(&mut self) -> serde_json::Value { - let message = tokio::time::timeout(RECV_TIMEOUT, self.socket.next()) - .await - .expect("frame arrives before timeout") - .expect("socket open") - .expect("frame has no socket error"); - let text = message.into_text().expect("frame is text"); - serde_json::from_str(&text).expect("frame is JSON") - } +fn multipart_body(file: &[u8], model: &str) -> (String, Vec) { + const BOUNDARY: &str = "gateway-stt-integration-boundary"; + let mut body = format!( + "--{BOUNDARY}\r\n\ + Content-Disposition: form-data; name=\"model\"\r\n\r\n\ + {model}\r\n\ + --{BOUNDARY}\r\n\ + Content-Disposition: form-data; name=\"response_format\"\r\n\r\n\ + json\r\n\ + --{BOUNDARY}\r\n\ + Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n\ + Content-Type: audio/wav\r\n\r\n" + ) + .into_bytes(); + body.extend_from_slice(file); + body.extend_from_slice(format!("\r\n--{BOUNDARY}--\r\n").as_bytes()); + (BOUNDARY.to_owned(), body) +} - pub(crate) async fn recv_until( - &mut self, - deadline: Duration, - keep: impl Fn(&serde_json::Value) -> bool, - ) -> serde_json::Value { - tokio::time::timeout(deadline, async { - loop { - let frame = self.recv_json().await; - if keep(&frame) { - break frame; - } - } - }) +pub(crate) async fn transcribe_batch( + service: SpeechService, + model: &str, + samples: &[f32], +) -> (StatusCode, serde_json::Value) { + let (boundary, body) = multipart_body(&wav_f32(samples), model); + let response = service + .routes() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/audio/transcriptions") + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .expect("batch request builds"), + ) .await - .expect("matching frame arrives before deadline") - } - - pub(crate) async fn close(mut self) { - self.socket.close(None).await.expect("socket closes"); - } + .expect("batch route answers"); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("batch response body reads"); + let json = serde_json::from_slice(&body).expect("batch response is JSON"); + (status, json) } diff --git a/crates/gateway-stt/tests/common/native_runtime.rs b/crates/gateway-stt/tests/common/native_runtime.rs new file mode 100644 index 00000000..9b532720 --- /dev/null +++ b/crates/gateway-stt/tests/common/native_runtime.rs @@ -0,0 +1,41 @@ +// ArtifactStore's blocking HTTP client owns a private Tokio runtime that must +// be created and dropped outside an async Tokio context. + +use std::time::Duration; + +use crate::SpeechService; + +pub(crate) fn start(config: gateway_config::Config) -> SpeechService { + let (result_tx, result_rx) = std::sync::mpsc::channel(); + let startup = std::thread::spawn(move || { + let service = SpeechService::new(); + let result = service + .prepare(&config, None) + .and_then(|prepared| service.begin_replacement(prepared)) + .and_then(|replacement| { + service.commit_replacement(replacement)?; + Ok(service) + }); + drop(result_tx.send(result)); + }); + let service = result_rx + .recv_timeout(Duration::from_secs(180)) + .expect("native runtime startup completes within its bound") + .expect("engine loads"); + startup.join().expect("runtime startup thread does not panic"); + service +} + +pub(crate) fn shutdown(service: SpeechService) { + let (finished_tx, finished_rx) = std::sync::mpsc::channel(); + let shutdown = std::thread::spawn(move || { + service.shutdown(); + let _ = finished_tx.send(()); + }); + finished_rx + .recv_timeout(Duration::from_secs(30)) + .expect("native runtime shutdown completes within its bound"); + shutdown + .join() + .expect("runtime shutdown thread does not panic"); +} diff --git a/crates/gateway-stt/tests/fixtures/audio/pcm16le-24khz.json b/crates/gateway-stt/tests/fixtures/audio/pcm16le-24khz.json new file mode 100644 index 00000000..426f2ca8 --- /dev/null +++ b/crates/gateway-stt/tests/fixtures/audio/pcm16le-24khz.json @@ -0,0 +1,8 @@ +{ + "encoding": "pcm_s16le", + "sample_rate_hz": 24000, + "channels": 1, + "samples": [-32768, -16384, -1, 0, 1, 16384, 32767], + "bytes": [0, 128, 0, 192, 255, 255, 0, 0, 1, 0, 0, 64, 255, 127], + "base64": "AIAAwP//AAABAABA/38=" +} diff --git a/crates/gateway-stt/tests/fixtures/realtime/client-events.json b/crates/gateway-stt/tests/fixtures/realtime/client-events.json new file mode 100644 index 00000000..934df03c --- /dev/null +++ b/crates/gateway-stt/tests/fixtures/realtime/client-events.json @@ -0,0 +1,37 @@ +{ + "input_audio_buffer_append": { + "type": "input_audio_buffer.append", + "audio": "AAABAP//", + "event_id": "client_append_1" + }, + "input_audio_buffer_clear": { + "type": "input_audio_buffer.clear" + }, + "input_audio_buffer_commit": { + "type": "input_audio_buffer.commit" + }, + "session_update": { + "type": "session.update", + "session": { + "type": "transcription", + "audio": { + "input": { + "format": { + "type": "audio/pcm", + "rate": 24000 + }, + "noise_reduction": null, + "transcription": { + "model": "realtime-transcribe", + "prompt": "meeting notes" + }, + "turn_detection": null + } + }, + "include": [ + "item.input_audio_transcription.hypothesis" + ] + }, + "event_id": "client_update_1" + } +} diff --git a/crates/gateway-stt/tests/fixtures/realtime/effective-sessions.json b/crates/gateway-stt/tests/fixtures/realtime/effective-sessions.json new file mode 100644 index 00000000..eabdbd56 --- /dev/null +++ b/crates/gateway-stt/tests/fixtures/realtime/effective-sessions.json @@ -0,0 +1,44 @@ +{ + "default": { + "id": "sess_canonical", + "object": "realtime.transcription_session", + "type": "transcription", + "audio": { + "input": { + "format": { + "type": "audio/pcm", + "rate": 24000 + }, + "noise_reduction": null, + "transcription": { + "model": "realtime-transcribe", + "prompt": "" + }, + "turn_detection": null + } + }, + "include": [] + }, + "updated": { + "id": "sess_canonical", + "object": "realtime.transcription_session", + "type": "transcription", + "audio": { + "input": { + "format": { + "type": "audio/pcm", + "rate": 24000 + }, + "noise_reduction": null, + "transcription": { + "model": "realtime-transcribe", + "prompt": "meeting notes" + }, + "turn_detection": null + } + }, + "include": [ + "item.input_audio_transcription.hypothesis" + ] + } +} diff --git a/crates/gateway-stt/tests/fixtures/realtime/invalid-sequences.json b/crates/gateway-stt/tests/fixtures/realtime/invalid-sequences.json new file mode 100644 index 00000000..4b3c2402 --- /dev/null +++ b/crates/gateway-stt/tests/fixtures/realtime/invalid-sequences.json @@ -0,0 +1,224 @@ +{ + "append_after_precommit_failure": { + "input": { "message": { "type": "input_audio_buffer.append", "audio": "AA==", "event_id": "client_append_after_failure" }, "setup": { "pending_precommit_failure": true } }, + "expected_error": { "event_id": "evt_append_after_failure", "type": "error", "error": { "type": "invalid_request_error", "code": "precommit_transcription_failed", "message": "Further appends are rejected after accurate precommit failure", "param": "audio", "event_id": "client_append_after_failure" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "append_invalid_base64": { + "input": { "message": { "type": "input_audio_buffer.append", "audio": "***", "event_id": "client_invalid_base64" } }, + "expected_error": { "event_id": "evt_invalid_base64", "type": "error", "error": { "type": "invalid_request_error", "code": "invalid_base64_audio", "message": "Audio must be valid Base64", "param": "audio", "event_id": "client_invalid_base64" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "append_limit_exceeded": { + "input": { "message": { "type": "input_audio_buffer.append", "audio": "AA==", "event_id": "client_append_limit" }, "materialize": { "decoded_audio_bytes": 15728641 } }, + "expected_error": { "event_id": "evt_append_limit", "type": "error", "error": { "type": "invalid_request_error", "code": "audio_append_too_large", "message": "Decoded audio exceeds the 15 MiB append limit", "param": "audio", "event_id": "client_append_limit" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "append_unknown_field": { + "input": { "message": { "type": "input_audio_buffer.append", "audio": "AA==", "extra": true, "event_id": "client_append_unknown" } }, + "expected_error": { "event_id": "evt_append_unknown", "type": "error", "error": { "type": "invalid_request_error", "code": "unknown_field", "message": "Unknown field extra", "param": "extra", "event_id": "client_append_unknown" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "clear_unknown_field": { + "input": { "message": { "type": "input_audio_buffer.clear", "extra": true, "event_id": "client_clear_unknown" } }, + "expected_error": { "event_id": "evt_clear_unknown", "type": "error", "error": { "type": "invalid_request_error", "code": "unknown_field", "message": "Unknown field extra", "param": "extra", "event_id": "client_clear_unknown" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "commit_short_audio": { + "input": { "message": { "type": "input_audio_buffer.commit", "event_id": "client_short_commit" }, "setup": { "buffered_audio_ms": 99 } }, + "expected_error": { "event_id": "evt_short_commit", "type": "error", "error": { "type": "invalid_request_error", "code": "audio_too_short", "message": "A commit requires at least 100 ms of audio", "param": "audio", "event_id": "client_short_commit" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "commit_unknown_field": { + "input": { "message": { "type": "input_audio_buffer.commit", "extra": true, "event_id": "client_commit_unknown" } }, + "expected_error": { "event_id": "evt_commit_unknown", "type": "error", "error": { "type": "invalid_request_error", "code": "unknown_field", "message": "Unknown field extra", "param": "extra", "event_id": "client_commit_unknown" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "dangling_pcm_byte_on_commit": { + "input": { "message": { "type": "input_audio_buffer.commit", "event_id": "client_odd_pcm" }, "setup": { "pending_pcm_bytes": [1] } }, + "expected_error": { "event_id": "evt_odd_pcm", "type": "error", "error": { "type": "invalid_request_error", "code": "invalid_pcm_audio", "message": "PCM16 audio ends with an incomplete sample", "param": "audio", "event_id": "client_odd_pcm" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "excessive_queue_lag": { + "input": { "message": { "type": "input_audio_buffer.append", "audio": "AAA=", "event_id": "client_queue_lag" }, "setup": { "audio_lag_ms": 2001 } }, + "expected_error": { "event_id": "evt_queue_lag", "type": "error", "error": { "type": "overload_error", "code": "audio_queue_lag", "message": "Audio queue lag exceeds two seconds", "param": "audio", "event_id": "client_queue_lag" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "invalid_client_event_id": { + "input": { "message": { "type": "input_audio_buffer.clear", "event_id": 7 } }, + "expected_error": { "event_id": "evt_invalid_client_id", "type": "error", "error": { "type": "invalid_request_error", "code": "invalid_event_id", "message": "event_id must be a string", "param": "event_id", "event_id": null } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "invalid_include_type": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "include": "item.input_audio_transcription.hypothesis" }, "event_id": "client_include_type" } }, + "expected_error": { "event_id": "evt_include_type", "type": "error", "error": { "type": "invalid_request_error", "code": "invalid_include", "message": "session.include must be an array", "param": "session.include", "event_id": "client_include_type" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "invalid_prompt_type": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "transcription": { "prompt": 7 } } } }, "event_id": "client_prompt_type" } }, + "expected_error": { "event_id": "evt_prompt_type", "type": "error", "error": { "type": "invalid_request_error", "code": "invalid_prompt", "message": "Transcription prompt must be a string", "param": "session.audio.input.transcription.prompt", "event_id": "client_prompt_type" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "malformed_json": { + "input": { "wire_text": "{\"type\":\"input_audio_buffer.clear\"" }, + "expected_error": { "event_id": "evt_malformed_json", "type": "error", "error": { "type": "invalid_request_error", "code": "invalid_json", "message": "The client event is not valid JSON" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "maximum_committed_items": { + "input": { "message": { "type": "input_audio_buffer.commit", "event_id": "client_item_limit" }, "setup": { "committed_items_finalizing": 4, "buffered_audio_ms": 100 } }, + "expected_error": { "event_id": "evt_item_limit", "type": "error", "error": { "type": "overload_error", "code": "too_many_committed_items", "message": "At most four committed items may finalize concurrently", "param": null, "event_id": "client_item_limit" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "maximum_unfinalized_audio": { + "input": { "message": { "type": "input_audio_buffer.append", "audio": "AAA=", "event_id": "client_audio_limit" }, "setup": { "unfinalized_audio_ms": 30000 } }, + "expected_error": { "event_id": "evt_audio_limit", "type": "error", "error": { "type": "overload_error", "code": "too_much_unfinalized_audio", "message": "Unfinalized audio exceeds 30 seconds", "param": "audio", "event_id": "client_audio_limit" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "missing_append_audio": { + "input": { "message": { "type": "input_audio_buffer.append", "event_id": "client_missing_audio" } }, + "expected_error": { "event_id": "evt_missing_audio", "type": "error", "error": { "type": "invalid_request_error", "code": "missing_required_field", "message": "Missing required field audio", "param": "audio", "event_id": "client_missing_audio" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "missing_client_event_type": { + "input": { "message": { "event_id": "client_missing_type" } }, + "expected_error": { "event_id": "evt_missing_type", "type": "error", "error": { "type": "invalid_request_error", "code": "missing_required_field", "message": "Missing required field type", "param": "type", "event_id": "client_missing_type" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "missing_session": { + "input": { "message": { "type": "session.update", "event_id": "client_missing_session" } }, + "expected_error": { "event_id": "evt_missing_session", "type": "error", "error": { "type": "invalid_request_error", "code": "missing_required_field", "message": "Missing required field session", "param": "session", "event_id": "client_missing_session" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "missing_session_type": { + "input": { "message": { "type": "session.update", "session": {}, "event_id": "client_missing_session_type" } }, + "expected_error": { "event_id": "evt_missing_session_type", "type": "error", "error": { "type": "invalid_request_error", "code": "missing_required_field", "message": "Missing required field session.type", "param": "session.type", "event_id": "client_missing_session_type" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "non_null_noise_reduction": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "noise_reduction": { "type": "near_field" } } } }, "event_id": "client_noise_reduction" } }, + "expected_error": { "event_id": "evt_noise_reduction", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_noise_reduction", "message": "Only null noise reduction is supported", "param": "session.audio.input.noise_reduction", "event_id": "client_noise_reduction" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "non_null_turn_detection": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "turn_detection": { "type": "server_vad" } } } }, "event_id": "client_turn_detection" } }, + "expected_error": { "event_id": "evt_turn_detection", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_turn_detection", "message": "Only null turn detection is supported", "param": "session.audio.input.turn_detection", "event_id": "client_turn_detection" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "result_queue_overload": { + "input": { "message": { "type": "input_audio_buffer.commit", "event_id": "client_result_overload" }, "setup": { "buffered_audio_ms": 100, "result_queue_occupancy": 16 } }, + "expected_error": { "event_id": "evt_result_overload", "type": "error", "error": { "type": "overload_error", "code": "result_queue_overload", "message": "The session result queue is full", "param": null, "event_id": "client_result_overload" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "session_audio_unknown_field": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": {}, "output": {} } }, "event_id": "client_audio_unknown" } }, + "expected_error": { "event_id": "evt_audio_unknown", "type": "error", "error": { "type": "invalid_request_error", "code": "unknown_field", "message": "Unknown field session.audio.output", "param": "session.audio.output", "event_id": "client_audio_unknown" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "session_input_unknown_field": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "extra": true } } }, "event_id": "client_input_unknown" } }, + "expected_error": { "event_id": "evt_input_unknown", "type": "error", "error": { "type": "invalid_request_error", "code": "unknown_field", "message": "Unknown field session.audio.input.extra", "param": "session.audio.input.extra", "event_id": "client_input_unknown" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "session_transcription_unknown_field": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "transcription": { "extra": true } } } }, "event_id": "client_transcription_unknown" } }, + "expected_error": { "event_id": "evt_transcription_unknown", "type": "error", "error": { "type": "invalid_request_error", "code": "unknown_field", "message": "Unknown field session.audio.input.transcription.extra", "param": "session.audio.input.transcription.extra", "event_id": "client_transcription_unknown" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "session_unknown_field": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "extra": true }, "event_id": "client_session_unknown" } }, + "expected_error": { "event_id": "evt_session_unknown", "type": "error", "error": { "type": "invalid_request_error", "code": "unknown_field", "message": "Unknown field session.extra", "param": "session.extra", "event_id": "client_session_unknown" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "session_update_unknown_field": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription" }, "extra": true, "event_id": "client_update_unknown" } }, + "expected_error": { "event_id": "evt_update_unknown", "type": "error", "error": { "type": "invalid_request_error", "code": "unknown_field", "message": "Unknown field extra", "param": "extra", "event_id": "client_update_unknown" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "unknown_event_type": { + "input": { "message": { "type": "response.create", "event_id": "client_unknown_type" } }, + "expected_error": { "event_id": "evt_unknown_type", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_event_type", "message": "Unsupported client event type response.create", "param": "type", "event_id": "client_unknown_type" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "unknown_include": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "include": ["item.input_audio_transcription.logprobs"] }, "event_id": "client_unknown_include" } }, + "expected_error": { "event_id": "evt_unknown_include", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_include", "message": "Unsupported include value item.input_audio_transcription.logprobs", "param": "session.include", "event_id": "client_unknown_include" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "unsupported_delay": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "transcription": { "delay_ms": 500 } } } }, "event_id": "client_delay" } }, + "expected_error": { "event_id": "evt_delay", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_delay", "message": "Transcription delay is not supported", "param": "session.audio.input.transcription.delay_ms", "event_id": "client_delay" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "unsupported_format_rate": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "format": { "type": "audio/pcm", "rate": 16000 } } } }, "event_id": "client_format_rate" } }, + "expected_error": { "event_id": "evt_format_rate", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_audio_format", "message": "Only 24 kHz PCM audio is supported", "param": "session.audio.input.format.rate", "event_id": "client_format_rate" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "unsupported_format_type": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "format": { "type": "audio/wav", "rate": 24000 } } } }, "event_id": "client_format_type" } }, + "expected_error": { "event_id": "evt_format_type", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_audio_format", "message": "Only audio/pcm is supported", "param": "session.audio.input.format.type", "event_id": "client_format_type" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "unsupported_keywords": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "transcription": { "keywords": ["PromptForge"] } } } }, "event_id": "client_keywords" } }, + "expected_error": { "event_id": "evt_keywords", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_keywords", "message": "Transcription keywords are not supported", "param": "session.audio.input.transcription.keywords", "event_id": "client_keywords" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "unsupported_language": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "transcription": { "language": "en" } } } }, "event_id": "client_language" } }, + "expected_error": { "event_id": "evt_language", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_language", "message": "A transcription language is not supported", "param": "session.audio.input.transcription.language", "event_id": "client_language" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "unsupported_logprobs": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "transcription": { "logprobs": true } } } }, "event_id": "client_logprobs" } }, + "expected_error": { "event_id": "evt_logprobs", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_logprobs", "message": "Transcription logprobs are not supported", "param": "session.audio.input.transcription.logprobs", "event_id": "client_logprobs" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "unsupported_model": { + "input": { "message": { "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "transcription": { "model": "whisper-1" } } } }, "event_id": "client_model" } }, + "expected_error": { "event_id": "evt_model", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_model", "message": "Only realtime-transcribe is supported", "param": "session.audio.input.transcription.model", "event_id": "client_model" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + }, + "wrong_session_type": { + "input": { "message": { "type": "session.update", "session": { "type": "realtime" }, "event_id": "client_session_type" } }, + "expected_error": { "event_id": "evt_session_type", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_session_type", "message": "Only transcription sessions are supported", "param": "session.type", "event_id": "client_session_type" } }, + "effective_session_after": "default", + "keeps_connection_usable": true + } +} diff --git a/crates/gateway-stt/tests/fixtures/realtime/server-events.json b/crates/gateway-stt/tests/fixtures/realtime/server-events.json new file mode 100644 index 00000000..d3eddd32 --- /dev/null +++ b/crates/gateway-stt/tests/fixtures/realtime/server-events.json @@ -0,0 +1,153 @@ +{ + "conversation_item_created": { + "event_id": "evt_item_created", + "type": "conversation.item.created", + "previous_item_id": null, + "item": { + "id": "item_alpha", + "type": "message", + "status": "completed", + "role": "user", + "content": [ + { + "type": "input_audio", + "transcript": null + } + ] + } + }, + "error_correlated": { + "event_id": "evt_error_correlated", + "type": "error", + "error": { + "type": "invalid_request_error", + "code": "unsupported_model", + "message": "Only realtime-transcribe is supported", + "param": "session.audio.input.transcription.model", + "event_id": "client_bad_update" + } + }, + "error_minimal": { + "event_id": "evt_error_minimal", + "type": "error", + "error": { + "type": "server_error", + "code": "internal_error", + "message": "Transcription failed" + } + }, + "error_uncorrelated": { + "event_id": "evt_error_uncorrelated", + "type": "error", + "error": { + "type": "server_error", + "code": "engine_replaced", + "message": "The speech engine was replaced", + "param": null, + "event_id": null + } + }, + "input_audio_buffer_cleared": { + "event_id": "evt_buffer_cleared", + "type": "input_audio_buffer.cleared" + }, + "input_audio_buffer_committed": { + "event_id": "evt_buffer_committed", + "type": "input_audio_buffer.committed", + "item_id": "item_alpha", + "previous_item_id": null + }, + "session_created": { + "event_id": "evt_session_created", + "type": "session.created", + "session": { + "id": "sess_canonical", + "object": "realtime.transcription_session", + "type": "transcription", + "audio": { + "input": { + "format": { + "type": "audio/pcm", + "rate": 24000 + }, + "noise_reduction": null, + "transcription": { + "model": "realtime-transcribe", + "prompt": "" + }, + "turn_detection": null + } + }, + "include": [] + } + }, + "session_updated": { + "event_id": "evt_session_updated", + "type": "session.updated", + "session": { + "id": "sess_canonical", + "object": "realtime.transcription_session", + "type": "transcription", + "audio": { + "input": { + "format": { + "type": "audio/pcm", + "rate": 24000 + }, + "noise_reduction": null, + "transcription": { + "model": "realtime-transcribe", + "prompt": "meeting notes" + }, + "turn_detection": null + } + }, + "include": [ + "item.input_audio_transcription.hypothesis" + ] + } + }, + "transcription_completed": { + "event_id": "evt_transcription_completed", + "type": "conversation.item.input_audio_transcription.completed", + "item_id": "item_alpha", + "content_index": 0, + "transcript": "Hello, world", + "usage": { + "type": "duration", + "seconds": 1.25 + } + }, + "transcription_delta": { + "event_id": "evt_transcription_delta", + "type": "conversation.item.input_audio_transcription.delta", + "item_id": "item_alpha", + "content_index": 0, + "delta": "Hello" + }, + "transcription_failed": { + "event_id": "evt_transcription_failed", + "type": "conversation.item.input_audio_transcription.failed", + "item_id": "item_beta", + "content_index": 0, + "error": { + "type": "server_error", + "code": "transcription_failed", + "message": "Authoritative transcription failed", + "param": "audio" + } + }, + "transcription_hypothesis": { + "event_id": "evt_transcription_hypothesis", + "type": "conversation.item.input_audio_transcription.hypothesis", + "item_id": "item_alpha", + "content_index": 0, + "revision": 3, + "transcript": "Hello, world", + "finalized": "Hello", + "agreed": ", wor", + "tentative": "ld", + "audio_start_ms": 0, + "audio_end_ms": 1250 + } +} diff --git a/crates/gateway-stt/tests/fixtures/realtime/valid-sequences.json b/crates/gateway-stt/tests/fixtures/realtime/valid-sequences.json new file mode 100644 index 00000000..ebccf775 --- /dev/null +++ b/crates/gateway-stt/tests/fixtures/realtime/valid-sequences.json @@ -0,0 +1,210 @@ +{ + "clear_retires_only_uncommitted_input": { + "events": [ + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAABAP//", "event_id": "client_clear_append" } }, + { "direction": "client", "message": { "type": "input_audio_buffer.clear", "event_id": "client_clear" } }, + { "direction": "server", "message": { "event_id": "evt_clear", "type": "input_audio_buffer.cleared" } } + ], + "invariants": [ + "clear cancels and retires only uncommitted work", + "clear resets partial PCM and resampler state", + "clear leaves committed items untouched" + ] + }, + "configuration_snapshot_isolation": { + "events": [ + { "direction": "server", "message": { "event_id": "evt_snapshot_created", "type": "session.created", "session": { "id": "sess_snapshot", "object": "realtime.transcription_session", "type": "transcription", "audio": { "input": { "format": { "type": "audio/pcm", "rate": 24000 }, "noise_reduction": null, "transcription": { "model": "realtime-transcribe", "prompt": "" }, "turn_detection": null } }, "include": [] } } }, + { "direction": "client", "message": { "event_id": "client_prompt_first", "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "transcription": { "model": "realtime-transcribe", "prompt": "first prompt" } } } } } }, + { "direction": "server", "message": { "event_id": "evt_prompt_first", "type": "session.updated", "session": { "id": "sess_snapshot", "object": "realtime.transcription_session", "type": "transcription", "audio": { "input": { "format": { "type": "audio/pcm", "rate": 24000 }, "noise_reduction": null, "transcription": { "model": "realtime-transcribe", "prompt": "first prompt" }, "turn_detection": null } }, "include": [] } } }, + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } }, + { "direction": "client", "message": { "event_id": "client_prompt_second", "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "transcription": { "model": "realtime-transcribe", "prompt": "second prompt" } } } } } }, + { "direction": "server", "message": { "event_id": "evt_prompt_second", "type": "session.updated", "session": { "id": "sess_snapshot", "object": "realtime.transcription_session", "type": "transcription", "audio": { "input": { "format": { "type": "audio/pcm", "rate": 24000 }, "noise_reduction": null, "transcription": { "model": "realtime-transcribe", "prompt": "second prompt" }, "turn_detection": null } }, "include": [] } } }, + { "direction": "client", "message": { "type": "input_audio_buffer.commit" } }, + { "direction": "server", "message": { "event_id": "evt_snapshot_commit", "type": "input_audio_buffer.committed", "item_id": "item_snapshot", "previous_item_id": null } }, + { "direction": "server", "message": { "event_id": "evt_snapshot_item", "type": "conversation.item.created", "previous_item_id": null, "item": { "id": "item_snapshot", "type": "message", "status": "completed", "role": "user", "content": [{ "type": "input_audio", "transcript": null }] } } }, + { "direction": "server", "message": { "event_id": "evt_snapshot_done", "type": "conversation.item.input_audio_transcription.completed", "item_id": "item_snapshot", "content_index": 0, "transcript": "first prompt remained attached", "usage": { "type": "duration", "seconds": 0.25 } } } + ], + "invariants": [ + "the first append snapshots format model prompt and include", + "the later update affects only the next input buffer", + "the update response contains the complete effective session" + ] + }, + "durable_lineage": { + "events": [ + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } }, + { "direction": "client", "message": { "type": "input_audio_buffer.commit" } }, + { "direction": "server", "message": { "event_id": "evt_lineage_commit_a", "type": "input_audio_buffer.committed", "item_id": "item_lineage_a", "previous_item_id": null } }, + { "direction": "server", "message": { "event_id": "evt_lineage_item_a", "type": "conversation.item.created", "previous_item_id": null, "item": { "id": "item_lineage_a", "type": "message", "status": "completed", "role": "user", "content": [{ "type": "input_audio", "transcript": null }] } } }, + { "direction": "server", "message": { "event_id": "evt_lineage_done_a", "type": "conversation.item.input_audio_transcription.completed", "item_id": "item_lineage_a", "content_index": 0, "transcript": "first", "usage": { "type": "duration", "seconds": 0.2 } } }, + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } }, + { "direction": "client", "message": { "type": "input_audio_buffer.commit" } }, + { "direction": "server", "message": { "event_id": "evt_lineage_commit_b", "type": "input_audio_buffer.committed", "item_id": "item_lineage_b", "previous_item_id": "item_lineage_a" } }, + { "direction": "server", "message": { "event_id": "evt_lineage_item_b", "type": "conversation.item.created", "previous_item_id": "item_lineage_a", "item": { "id": "item_lineage_b", "type": "message", "status": "completed", "role": "user", "content": [{ "type": "input_audio", "transcript": null }] } } } + ], + "invariants": [ + "durable commit order survives removal of completed items", + "previous_item_id is determined only by commit order" + ] + }, + "engine_replacement": { + "events": [ + { "direction": "server", "message": { "event_id": "evt_replaced_item", "type": "conversation.item.input_audio_transcription.failed", "item_id": "item_replaced", "content_index": 0, "error": { "type": "server_error", "code": "engine_replaced", "message": "The speech engine was replaced", "param": null } } }, + { "direction": "server", "message": { "event_id": "evt_replaced_general", "type": "error", "error": { "type": "server_error", "code": "engine_replaced", "message": "The speech engine was replaced", "param": null, "event_id": null } } } + ], + "invariants": [ + "every committed in-flight item fails exactly once", + "uncommitted audio receives one general error", + "the server closes with code 1012 and reason engine_replaced" + ] + }, + "first_event_readiness": { + "events": [ + { "direction": "server", "message": { "event_id": "evt_ready", "type": "session.created", "session": { "id": "sess_ready", "object": "realtime.transcription_session", "type": "transcription", "audio": { "input": { "format": { "type": "audio/pcm", "rate": 24000 }, "noise_reduction": null, "transcription": { "model": "realtime-transcribe", "prompt": "" }, "turn_detection": null } }, "include": [] } } } + ], + "invariants": [ + "session.created is the first server event", + "the connection starts ready with the advertised defaults" + ] + }, + "hypothesis_negotiation": { + "events": [ + { "direction": "client", "message": { "event_id": "client_hypothesis", "type": "session.update", "session": { "type": "transcription", "include": ["item.input_audio_transcription.hypothesis"] } } }, + { "direction": "server", "message": { "event_id": "evt_hypothesis_updated", "type": "session.updated", "session": { "id": "sess_hypothesis", "object": "realtime.transcription_session", "type": "transcription", "audio": { "input": { "format": { "type": "audio/pcm", "rate": 24000 }, "noise_reduction": null, "transcription": { "model": "realtime-transcribe", "prompt": "" }, "turn_detection": null } }, "include": ["item.input_audio_transcription.hypothesis"] } } }, + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAABAP//" } }, + { "direction": "server", "message": { "event_id": "evt_hypothesis", "type": "conversation.item.input_audio_transcription.hypothesis", "item_id": "item_hypothesis", "content_index": 0, "revision": 1, "transcript": "Hello", "finalized": "Hel", "agreed": "l", "tentative": "o", "audio_start_ms": 0, "audio_end_ms": 250 } }, + { "direction": "server", "message": { "event_id": "evt_hypothesis_2", "type": "conversation.item.input_audio_transcription.hypothesis", "item_id": "item_hypothesis", "content_index": 0, "revision": 2, "transcript": "Hello!", "finalized": "Hello", "agreed": "", "tentative": "!", "audio_start_ms": 0, "audio_end_ms": 300 } }, + { "direction": "server", "message": { "event_id": "evt_hypothesis_done", "type": "conversation.item.input_audio_transcription.completed", "item_id": "item_hypothesis", "content_index": 0, "transcript": "Hello", "usage": { "type": "duration", "seconds": 0.25 } } } + ], + "invariants": [ + "hypotheses are emitted only after exact include negotiation", + "hypothesis revisions increase monotonically", + "completion is authoritative" + ] + }, + "producer_hypothesis_ownership": { + "events": [ + { "direction": "client", "message": { "event_id": "client_producer_hypothesis", "type": "session.update", "session": { "type": "transcription", "include": ["item.input_audio_transcription.hypothesis"] } } }, + { "direction": "server", "message": { "event_id": "evt_producer_hypothesis_3", "type": "conversation.item.input_audio_transcription.hypothesis", "item_id": "item_producer_hypothesis", "content_index": 0, "revision": 3, "transcript": "ask not your country new tail first", "finalized": "ask not your country", "agreed": "", "tentative": " new tail first", "audio_start_ms": 0, "audio_end_ms": 8100 } }, + { "direction": "server", "message": { "event_id": "evt_producer_hypothesis_4", "type": "conversation.item.input_audio_transcription.hypothesis", "item_id": "item_producer_hypothesis", "content_index": 0, "revision": 4, "transcript": "ask not your country new tail second", "finalized": "ask not your country", "agreed": " new tail", "tentative": " second", "audio_start_ms": 0, "audio_end_ms": 8200 } } + ], + "invariants": [ + "producer snapshots partition finalized agreed and tentative text without overlap", + "each field owns its exact leading boundary whitespace", + "the visible transcript is the fields concatenated exactly once" + ] + }, + "immediate_commit_and_provisional_promotion": { + "events": [ + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } }, + { "direction": "client", "message": { "event_id": "client_commit", "type": "input_audio_buffer.commit" } }, + { "direction": "server", "message": { "event_id": "evt_commit", "type": "input_audio_buffer.committed", "item_id": "item_provisional", "previous_item_id": null } }, + { "direction": "server", "message": { "event_id": "evt_item", "type": "conversation.item.created", "previous_item_id": null, "item": { "id": "item_provisional", "type": "message", "status": "completed", "role": "user", "content": [{ "type": "input_audio", "transcript": null }] } } } + ], + "invariants": [ + "commit acknowledgment and item creation are immediate", + "the provisional item ID is promoted unchanged", + "server IDs are not derived from the client event ID" + ] + }, + "optional_client_ids_and_error_correlation": { + "events": [ + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "event_id": "client_optional" } }, + { "direction": "client", "message": { "type": "input_audio_buffer.commit" } }, + { "direction": "client", "message": { "event_id": "client_bad_update", "type": "session.update", "session": { "type": "transcription", "audio": { "input": { "transcription": { "model": "not-supported" } } } } } }, + { "direction": "server", "message": { "event_id": "evt_bad_update", "type": "error", "error": { "type": "invalid_request_error", "code": "unsupported_model", "message": "Only realtime-transcribe is supported", "param": "session.audio.input.transcription.model", "event_id": "client_bad_update" } } } + ], + "invariants": [ + "client event IDs are optional opaque strings", + "a client event ID is echoed only in its correlated error", + "server event IDs are independently generated" + ] + }, + "overlapping_items_reverse_completion": { + "events": [ + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } }, + { "direction": "client", "message": { "type": "input_audio_buffer.commit" } }, + { "direction": "server", "message": { "event_id": "evt_overlap_commit_a", "type": "input_audio_buffer.committed", "item_id": "item_overlap_a", "previous_item_id": null } }, + { "direction": "server", "message": { "event_id": "evt_overlap_item_a", "type": "conversation.item.created", "previous_item_id": null, "item": { "id": "item_overlap_a", "type": "message", "status": "completed", "role": "user", "content": [{ "type": "input_audio", "transcript": null }] } } }, + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } }, + { "direction": "client", "message": { "type": "input_audio_buffer.commit" } }, + { "direction": "server", "message": { "event_id": "evt_overlap_commit_b", "type": "input_audio_buffer.committed", "item_id": "item_overlap_b", "previous_item_id": "item_overlap_a" } }, + { "direction": "server", "message": { "event_id": "evt_overlap_item_b", "type": "conversation.item.created", "previous_item_id": "item_overlap_a", "item": { "id": "item_overlap_b", "type": "message", "status": "completed", "role": "user", "content": [{ "type": "input_audio", "transcript": null }] } } }, + { "direction": "server", "message": { "event_id": "evt_overlap_done_b", "type": "conversation.item.input_audio_transcription.completed", "item_id": "item_overlap_b", "content_index": 0, "transcript": "second", "usage": { "type": "duration", "seconds": 0.2 } } }, + { "direction": "server", "message": { "event_id": "evt_overlap_done_a", "type": "conversation.item.input_audio_transcription.completed", "item_id": "item_overlap_a", "content_index": 0, "transcript": "first", "usage": { "type": "duration", "seconds": 0.2 } } } + ], + "invariants": [ + "committed items finalize independently", + "completion order may differ from durable commit order", + "each item has one terminal outcome" + ] + }, + "pending_precommit_failure_clear": { + "events": [ + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAABAP//" } }, + { "direction": "client", "message": { "type": "input_audio_buffer.clear" } }, + { "direction": "server", "message": { "event_id": "evt_pending_clear", "type": "input_audio_buffer.cleared" } } + ], + "invariants": [ + "clear discards a pending precommit failure", + "clear does not invent an item or emit an item failure" + ] + }, + "pending_precommit_failure_commit": { + "events": [ + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } }, + { "direction": "client", "message": { "type": "input_audio_buffer.commit" } }, + { "direction": "server", "message": { "event_id": "evt_pending_commit", "type": "input_audio_buffer.committed", "item_id": "item_pending", "previous_item_id": null } }, + { "direction": "server", "message": { "event_id": "evt_pending_item", "type": "conversation.item.created", "previous_item_id": null, "item": { "id": "item_pending", "type": "message", "status": "completed", "role": "user", "content": [{ "type": "input_audio", "transcript": null }] } } }, + { "direction": "server", "message": { "event_id": "evt_pending_failed", "type": "conversation.item.input_audio_transcription.failed", "item_id": "item_pending", "content_index": 0, "error": { "type": "server_error", "code": "precommit_transcription_failed", "message": "Accurate precommit transcription failed", "param": null } } } + ], + "invariants": [ + "commit establishes the item before reporting the pending failure", + "the item receives exactly one terminal failure" + ] + }, + "saturated_commit_retry": { + "events": [ + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } }, + { "direction": "client", "message": { "event_id": "client_commit_saturated", "type": "input_audio_buffer.commit" } }, + { "direction": "server", "message": { "event_id": "evt_commit_saturated", "type": "error", "error": { "type": "overload_error", "code": "too_many_committed_items", "message": "At most four committed items may finalize concurrently", "param": null, "event_id": "client_commit_saturated" } } }, + { "direction": "server", "message": { "event_id": "evt_capacity_released", "type": "conversation.item.input_audio_transcription.completed", "item_id": "item_existing", "content_index": 0, "transcript": "released", "usage": { "type": "duration", "seconds": 0.2 } } }, + { "direction": "client", "message": { "event_id": "client_commit_retry", "type": "input_audio_buffer.commit" } }, + { "direction": "server", "message": { "event_id": "evt_commit_retry", "type": "input_audio_buffer.committed", "item_id": "item_retry", "previous_item_id": "item_existing" } }, + { "direction": "server", "message": { "event_id": "evt_item_retry", "type": "conversation.item.created", "previous_item_id": "item_existing", "item": { "id": "item_retry", "type": "message", "status": "completed", "role": "user", "content": [{ "type": "input_audio", "transcript": null }] } } } + ], + "invariants": [ + "commit reserves every bounded resource before detaching input", + "a saturated commit leaves the same provisional input retryable", + "retry promotes the original provisional item ID" + ] + }, + "segment_admission_failure": { + "events": [ + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } }, + { "direction": "client", "message": { "type": "input_audio_buffer.commit" } }, + { "direction": "server", "message": { "event_id": "evt_segment_commit", "type": "input_audio_buffer.committed", "item_id": "item_segment", "previous_item_id": null } }, + { "direction": "server", "message": { "event_id": "evt_segment_item", "type": "conversation.item.created", "previous_item_id": null, "item": { "id": "item_segment", "type": "message", "status": "completed", "role": "user", "content": [{ "type": "input_audio", "transcript": null }] } } }, + { "direction": "server", "message": { "event_id": "evt_segment_failed", "type": "conversation.item.input_audio_transcription.failed", "item_id": "item_segment", "content_index": 0, "error": { "type": "overload_error", "code": "final_segment_overload", "message": "The authoritative segment could not be admitted", "param": null } } } + ], + "invariants": [ + "authoritative segment admission fails the item atomically", + "the item never completes with a transcript hole" + ] + }, + "standard_delta_after_item_creation": { + "events": [ + { "direction": "client", "message": { "type": "input_audio_buffer.append", "audio": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } }, + { "direction": "client", "message": { "type": "input_audio_buffer.commit" } }, + { "direction": "server", "message": { "event_id": "evt_delta_commit", "type": "input_audio_buffer.committed", "item_id": "item_delta", "previous_item_id": null } }, + { "direction": "server", "message": { "event_id": "evt_delta_item", "type": "conversation.item.created", "previous_item_id": null, "item": { "id": "item_delta", "type": "message", "status": "completed", "role": "user", "content": [{ "type": "input_audio", "transcript": null }] } } }, + { "direction": "server", "message": { "event_id": "evt_delta", "type": "conversation.item.input_audio_transcription.delta", "item_id": "item_delta", "content_index": 0, "delta": "Hello" } }, + { "direction": "server", "message": { "event_id": "evt_delta_done", "type": "conversation.item.input_audio_transcription.completed", "item_id": "item_delta", "content_index": 0, "transcript": "Hello", "usage": { "type": "duration", "seconds": 0.2 } } } + ], + "invariants": [ + "standard deltas follow conversation item creation", + "accepted deltas are not internally dropped while the peer remains writable", + "completion remains authoritative" + ] + } +} diff --git a/crates/gateway-stt/tests/it/architecture.rs b/crates/gateway-stt/tests/it/architecture.rs new file mode 100644 index 00000000..7d5521d2 --- /dev/null +++ b/crates/gateway-stt/tests/it/architecture.rs @@ -0,0 +1,1566 @@ +//! Architecture ratchets for the four-crate STT stack. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::OnceLock; + +const STT_CRATES: [&str; 4] = [ + "gateway-stt", + "gateway-stt-engine", + "gateway-stt-backend-whisper", + "gateway-whisper-ffi", +]; + +const PUBLIC_ROOT_COUNTS: [(&str, usize); 4] = [ + ("gateway-stt", 6), + ("gateway-stt-engine", 7), + ("gateway-stt-backend-whisper", 2), + ("gateway-whisper-ffi", 6), +]; + +const TEST_FIXTURE_PUBLIC_ROOT_COUNTS: [(&str, usize); 2] = + [("gateway-stt", 7), ("gateway-stt-engine", 8)]; + +const LEGACY_WORKSHOP_UI_SPEECH_SEAMS: [&str; 6] = [ + "setupLegacyStt", + "sttCapability", + "interface StreamFrame", + "interface InterimFrame", + "interface FinalFrame", + "pcm-capture", +]; + +const DEPENDENCY_POLICY_CRATES: [&str; 7] = [ + "gateway", + "gateway-stt", + "gateway-stt-engine", + "gateway-stt-backend-whisper", + "gateway-whisper-ffi", + "shared-loopback", + "workshop-server", +]; + +struct DependencyPolicy { + crate_name: &'static str, + final_edges: &'static [&'static str], +} + +const DEPENDENCY_POLICIES: [DependencyPolicy; 7] = [ + DependencyPolicy { + crate_name: "gateway", + final_edges: &[ + "gateway-config", + "gateway-config-ui", + "gateway-local", + "gateway-logging", + "gateway-routing", + "gateway-stt", + "gateway-stt-engine", + "gateway-web-search", + "promptforge-core", + "shared-loopback", + "shared-progress", + "shared-protocol", + "shared-sidecar", + ], + }, + DependencyPolicy { + crate_name: "gateway-stt", + final_edges: &[ + "gateway-config", + "gateway-local", + "gateway-stt-backend-whisper", + "gateway-stt-engine", + "shared-progress", + ], + }, + DependencyPolicy { + crate_name: "gateway-stt-engine", + final_edges: &[], + }, + DependencyPolicy { + crate_name: "gateway-stt-backend-whisper", + final_edges: &[ + "gateway-stt-engine", + "gateway-whisper-ffi", + "shared-progress", + ], + }, + DependencyPolicy { + crate_name: "gateway-whisper-ffi", + final_edges: &[], + }, + DependencyPolicy { + crate_name: "shared-loopback", + final_edges: &[], + }, + DependencyPolicy { + crate_name: "workshop-server", + final_edges: &[ + "build-ui", + "promptforge-agent", + "promptforge-core-support", + "promptforge-model-client", + "promptforge-store", + "promptforge-tools", + "shared-loopback", + "shared-progress", + "shared-sidecar", + ], + }, +]; + +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct CeilingsFile { + public_root_count: usize, + test_fixture_public_root_count: Option, + modules: BTreeMap, +} + +#[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, +} + +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .unwrap_or_else(|| panic!("gateway-stt is nested under the workspace crates directory")) + .to_owned() +} + +fn crate_root(crate_name: &str) -> PathBuf { + workspace_root().join("crates").join(crate_name) +} + +fn read(path: &Path) -> String { + fs::read_to_string(path).unwrap_or_else(|error| { + panic!("{} must be readable UTF-8: {error}", path.display()); + }) +} + +fn rust_sources(root: &Path) -> Vec { + fn collect(directory: &Path, sources: &mut Vec) { + for entry in fs::read_dir(directory).unwrap_or_else(|error| { + panic!("{} must be readable: {error}", directory.display()); + }) { + let path = entry + .unwrap_or_else(|error| panic!("source directory entry must be readable: {error}")) + .path(); + if path.is_dir() { + collect(&path, sources); + } else if path.extension().is_some_and(|extension| extension == "rs") { + sources.push(path); + } + } + } + + let mut sources = Vec::new(); + collect(root, &mut sources); + sources.sort(); + sources +} + +fn forbidden_legacy_speech_seams<'a>(source: &str, forbidden: &'a [&'a str]) -> Vec<&'a str> { + forbidden + .iter() + .copied() + .filter(|symbol| source.contains(symbol)) + .collect() +} + +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 metadata_package<'a>(metadata: &'a CargoMetadata, crate_name: &str) -> &'a MetadataPackage { + metadata + .packages + .iter() + .find(|package| package.name == crate_name) + .unwrap_or_else(|| panic!("cargo metadata must contain workspace package {crate_name}")) +} + +fn crate_workspace_edges(metadata: &CargoMetadata, crate_name: &str) -> BTreeSet { + let workspace_members = metadata.workspace_members.iter().collect::>(); + let package_names_by_path = metadata + .packages + .iter() + .filter(|package| workspace_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::>(); + + metadata_package(metadata, crate_name) + .dependencies + .iter() + .filter_map(|dependency| { + dependency + .path + .as_ref() + .and_then(|path| package_names_by_path.get(path)) + .copied() + }) + .filter(|dependency| *dependency != crate_name) + .map(str::to_owned) + .collect() +} + +fn validate_dependency_policies(policies: &[DependencyPolicy]) -> Result<(), String> { + let expected = DEPENDENCY_POLICY_CRATES + .into_iter() + .collect::>(); + let actual = policies + .iter() + .map(|policy| policy.crate_name) + .collect::>(); + if actual.len() != policies.len() { + return Err("dependency policies contain a duplicate crate".to_owned()); + } + if actual != expected { + return Err(format!( + "dependency policies must cover the exact final crates: expected {expected:?}, got \ + {actual:?}" + )); + } + Ok(()) +} + +fn dependency_drift_message(crate_name: &str) -> String { + format!("{crate_name} workspace edges drifted from the exact final allowlist") +} + +#[test] +fn workspace_dependencies_match_exact_final_allowlists() { + let metadata = workspace_metadata(); + validate_dependency_policies(&DEPENDENCY_POLICIES).unwrap_or_else(|error| panic!("{error}")); + for policy in &DEPENDENCY_POLICIES { + let allowed = policy + .final_edges + .iter() + .copied() + .map(str::to_owned) + .collect::>(); + assert_eq!( + crate_workspace_edges(metadata, policy.crate_name), + allowed, + "{}", + dependency_drift_message(policy.crate_name) + ); + } +} + +#[test] +fn dependency_drift_diagnostic_names_the_final_invariant() { + assert_eq!( + dependency_drift_message("gateway-stt"), + "gateway-stt workspace edges drifted from the exact final allowlist" + ); +} + +#[test] +fn dependency_policy_omission_is_rejected() { + assert!( + validate_dependency_policies(&DEPENDENCY_POLICIES[..DEPENDENCY_POLICIES.len() - 1]) + .is_err() + ); +} + +#[test] +fn dependency_policy_is_final_without_workshop_back_edges() { + let workshop = DEPENDENCY_POLICIES + .iter() + .find(|policy| policy.crate_name == "workshop-server") + .unwrap_or_else(|| panic!("final policy contains the Workshop dependency policy")); + assert!( + workshop.final_edges.contains(&"shared-loopback"), + "final policy retains the Workshop dependency on shared-loopback" + ); + assert!( + !DEPENDENCY_POLICIES + .iter() + .find(|policy| policy.crate_name == "gateway-stt") + .unwrap_or_else(|| panic!("final policy contains gateway-stt")) + .final_edges + .contains(&"workshop-server"), + "final policy forbids the Gateway STT to Workshop dependency" + ); +} + +#[test] +fn legacy_speech_seams_are_absent_from_production_sources() { + let gateway_stt = rust_sources(&crate_root("gateway-stt").join("src")) + .into_iter() + .map(|path| read(&path)) + .collect::(); + for forbidden in [ + "mod stt;", + "crate::stt::", + "workshop_server", + "workshop_status", + "x-promptforge-workshop-status", + "workshop_routes", + ] { + assert!( + !gateway_stt.contains(forbidden), + "gateway-stt production sources must not contain legacy seam `{forbidden}`" + ); + } + + let workshop = rust_sources(&crate_root("workshop-server").join("src")) + .into_iter() + .map(|path| read(&path)) + .collect::(); + for forbidden in [ + "pub(crate) mod stt;", + "routes::stt", + "GatewaySttSocket", + "connect_stt", + "workshop_status", + "x-promptforge-workshop-status", + "spawn_with_routes", + ] { + assert!( + !workshop.contains(forbidden), + "Workshop production sources must not contain legacy seam `{forbidden}`" + ); + } + + let workshop_ui = [ + read(&crate_root("workshop-server").join("ui/src/ui/stt.ts")), + read(&crate_root("workshop-server").join("ui/src/services/protocol.ts")), + read(&crate_root("workshop-server").join("ui/pcm-worklet.js")), + ] + .concat(); + let forbidden = forbidden_legacy_speech_seams(&workshop_ui, &LEGACY_WORKSHOP_UI_SPEECH_SEAMS); + assert!( + forbidden.is_empty(), + "Workshop UI production sources must not contain legacy seams: {forbidden:?}" + ); +} + +#[test] +fn legacy_workshop_ui_gate_rejects_all_legacy_forms_without_current_capture_false_positives() { + for forbidden in LEGACY_WORKSHOP_UI_SPEECH_SEAMS { + assert_eq!( + forbidden_legacy_speech_seams(forbidden, &LEGACY_WORKSHOP_UI_SPEECH_SEAMS), + [forbidden], + "the zero-symbol gate must reject legacy production symbol `{forbidden}`" + ); + } + + let adversarial_pcm_forms = [ + r#"registerProcessor("pcm-capture", Processor);"#, + r#"new AudioWorkletNode(context, "pcm-capture");"#, + "`pcm-capture requires a 24 kHz AudioContext`", + ]; + for source in adversarial_pcm_forms { + assert_eq!( + forbidden_legacy_speech_seams(source, &LEGACY_WORKSHOP_UI_SPEECH_SEAMS), + ["pcm-capture"], + "the zero-symbol gate must reject the processor ID in every production context" + ); + } + + let retained_capture = [ + "pcm16-capture", + "Pcm16CaptureProcessor", + "RealtimeTranscriptionService", + "SpeechCaptureService", + "AudioWorkletNode", + "getUserMedia", + ] + .join("\n"); + assert!( + forbidden_legacy_speech_seams(&retained_capture, &LEGACY_WORKSHOP_UI_SPEECH_SEAMS) + .is_empty(), + "the zero-symbol gate must retain current Realtime and browser capture behavior" + ); +} + +#[test] +fn metadata_edges_include_renames_local_paths_targets_and_all_kinds() { + let fixture = r#" + { + "workspace_members": ["source", "normal", "development", "build", "target"], + "packages": [ + { + "name": "source", + "id": "source", + "manifest_path": "/workspace/source/Cargo.toml", + "targets": [], + "dependencies": [ + {"name": "normal", "path": "/workspace/normal", "kind": null, "rename": "renamed"}, + {"name": "development", "path": "/workspace/development", "kind": "dev"}, + {"name": "build", "path": "/workspace/build", "kind": "build"}, + {"name": "target", "path": "/workspace/target", "kind": null, "target": "cfg(unix)"}, + {"name": "external", "path": null, "kind": null} + ] + }, + { + "name": "normal", "id": "normal", + "manifest_path": "/workspace/normal/Cargo.toml", "targets": [], "dependencies": [] + }, + { + "name": "development", "id": "development", + "manifest_path": "/workspace/development/Cargo.toml", "targets": [], "dependencies": [] + }, + { + "name": "build", "id": "build", + "manifest_path": "/workspace/build/Cargo.toml", "targets": [], "dependencies": [] + }, + { + "name": "target", "id": "target", + "manifest_path": "/workspace/target/Cargo.toml", "targets": [], "dependencies": [] + } + ] + }"#; + let metadata: CargoMetadata = + serde_json::from_str(fixture).expect("adversarial metadata fixture parses"); + + assert_eq!( + crate_workspace_edges(&metadata, "source"), + ["build", "development", "normal", "target"] + .into_iter() + .map(str::to_owned) + .collect() + ); +} + +fn ceilings(crate_name: &str) -> CeilingsFile { + let path = crate_root(crate_name).join("module-ceilings.toml"); + parse_ceilings(&read(&path)) + .unwrap_or_else(|error| panic!("{} must parse as TOML: {error}", path.display())) +} + +fn parse_ceilings(source: &str) -> Result { + toml::from_str(source) +} + +fn relative_source_path(src: &Path, source: &Path) -> String { + source + .strip_prefix(src) + .unwrap_or_else(|_| panic!("source lives below its crate src directory")) + .to_string_lossy() + .replace('\\', "/") +} + +fn expected_public_root_count(crate_name: &str) -> usize { + PUBLIC_ROOT_COUNTS + .iter() + .find_map(|(name, count)| (*name == crate_name).then_some(*count)) + .unwrap_or_else(|| panic!("public-root policy must cover {crate_name}")) +} + +fn expected_test_fixture_public_root_count(crate_name: &str) -> Option { + TEST_FIXTURE_PUBLIC_ROOT_COUNTS + .iter() + .find_map(|(name, count)| (*name == crate_name).then_some(*count)) +} + +fn validate_module_ceiling(lines: usize, ceiling: usize) -> Result<(), String> { + if lines != ceiling { + return Err(format!( + "measured {lines} physical lines but the exact ceiling is {ceiling}" + )); + } + if lines > 500 { + return Err(format!( + "final module has {lines} physical lines above the 500-line limit" + )); + } + Ok(()) +} + +#[test] +fn final_module_ceilings_cover_every_source() { + for crate_name in STT_CRATES { + let src = crate_root(crate_name).join("src"); + let config = ceilings(crate_name); + assert_eq!( + config.public_root_count, + expected_public_root_count(crate_name), + "{crate_name} public root count drifted from the exact final policy" + ); + assert_eq!( + config.test_fixture_public_root_count, + expected_test_fixture_public_root_count(crate_name), + "{crate_name} test-fixtures public root count drifted from the exact final policy" + ); + let measured = rust_sources(&src) + .into_iter() + .map(|source| { + let relative = relative_source_path(&src, &source); + let lines = read(&source).lines().count(); + (relative, lines) + }) + .collect::>(); + + assert_eq!( + config.modules.keys().collect::>(), + measured.keys().collect::>(), + "{crate_name} module ceilings must list exactly its Rust source files" + ); + for (module, lines) in measured { + let ceiling = config.modules[&module]; + validate_module_ceiling(lines, ceiling).unwrap_or_else(|error| { + panic!("{crate_name}/{module} violates its source policy: {error}") + }); + } + } +} + +#[test] +fn exact_module_ceiling_policy_rejects_spare_growth_and_every_oversize() { + assert!(validate_module_ceiling(499, 500).is_err()); + assert!(validate_module_ceiling(501, 501).is_err()); + assert!(validate_module_ceiling(500, 500).is_ok()); +} + +#[test] +fn stale_migration_section_is_rejected() { + let malformed = r#" + public_root_count = 2 + + [migration_targets] + + [modules] + "lib.rs" = 1 + "#; + + assert!(parse_ceilings(malformed).is_err()); +} + +const REFCOUNT_INTROSPECTION_OWNERS: [&str; 3] = ["Arc", "Rc", "Weak"]; +const REFCOUNT_INTROSPECTION_METHODS: [&str; 11] = [ + "decrement_strong_count", + "get_mut", + "get_mut_unchecked", + "increment_strong_count", + "into_inner", + "is_unique", + "make_mut", + "strong_count", + "try_unwrap", + "unwrap_or_clone", + "weak_count", +]; + +fn calls_associated_method(source: &str, owner: &str, method: &str) -> bool { + let compact = source + .chars() + .filter(|character| !character.is_whitespace()) + .collect::(); + let marker = format!("{owner}::"); + let mut remainder = compact.as_str(); + while let Some(position) = remainder.find(&marker) { + let mut candidate = &remainder[position + marker.len()..]; + if let Some(generic) = candidate.strip_prefix('<') { + let mut depth = 1_usize; + let mut end = None; + for (index, character) in generic.char_indices() { + match character { + '<' => depth += 1, + '>' => { + depth -= 1; + if depth == 0 { + end = Some(index + character.len_utf8()); + break; + } + } + _ => {} + } + } + let Some(end) = end else { + return false; + }; + let Some(after_generic) = generic[end..].strip_prefix("::") else { + return false; + }; + candidate = after_generic; + } + if candidate.starts_with(&format!("{method}(")) { + return true; + } + remainder = &remainder[position + marker.len()..]; + } + false +} + +fn refcount_introspection(source: &str) -> Option<(&'static str, &'static str)> { + REFCOUNT_INTROSPECTION_OWNERS + .into_iter() + .flat_map(|owner| { + REFCOUNT_INTROSPECTION_METHODS + .into_iter() + .map(move |method| (owner, method)) + }) + .find(|(owner, method)| calls_associated_method(source, owner, method)) +} + +#[test] +fn every_reference_count_introspection_form_is_rejected() { + for owner in REFCOUNT_INTROSPECTION_OWNERS { + for method in REFCOUNT_INTROSPECTION_METHODS { + let direct = format!("let _ = {owner}::{method}(&value);"); + assert_eq!(refcount_introspection(&direct), Some((owner, method))); + let generic = format!("let _ = {owner}::>::{method}(&value);"); + assert_eq!(refcount_introspection(&generic), Some((owner, method))); + } + } + assert_eq!(refcount_introspection("Arc::clone(&value)"), None); + assert_eq!(refcount_introspection("Arc::ptr_eq(&left, &right)"), None); + assert_eq!(refcount_introspection("Weak::upgrade(&owner)"), None); +} + +#[test] +fn generation_quiescence_uses_explicit_ownership_without_item_transfer() { + let generation = read(&crate_root("gateway-stt").join("src/generation.rs")); + let replacement = read(&crate_root("gateway-stt").join("src/replacement.rs")); + for source in rust_sources(&crate_root("gateway-stt").join("src")) { + let contents = read(&source); + assert!( + refcount_introspection(&contents).is_none(), + "{} must not infer lifecycle ownership from reference counts", + source.display() + ); + } + for policy in [ + "requests: usize", + "jobs: usize", + "struct SessionEpoch", + "struct ReplacementCoordinator", + ] { + assert!( + replacement.contains(policy), + "replacement policy must retain {policy}" + ); + } + assert!( + !generation.contains("CommittedItem") && !replacement.contains("CommittedItem"), + "Realtime sessions retain committed-item failure ownership" + ); +} + +#[test] +fn realtime_retirement_is_registry_owned_event_driven_and_keeps_state_pure() { + let registry = read(&crate_root("gateway-stt").join("src/realtime/registry.rs")); + let state = registry + .split_once("struct RegistryState {") + .and_then(|(_, rest)| rest.split_once('}')) + .map_or_else( + || panic!("Realtime registry must retain explicit state"), + |(body, _)| body, + ); + + assert!(state.contains("active: usize")); + for runtime_type in ["JoinHandle", "Notify", "AtomicUsize"] { + assert!( + !state.contains(runtime_type), + "pure registry accounting must not contain {runtime_type}" + ); + } + for policy in [ + "tokio::spawn(async move", + "task.abort();", + "task.await", + "join_retired_tasks(finalization_tasks)", + "error.is_cancelled()", + "record_retired_task_failures", + "self.release_admission();", + "self.cleanup.emit();", + ] { + assert!( + registry.contains(policy), + "registry-owned retirement must retain {policy}" + ); + } + for polling in ["noop_waker", "poll_join", "reap_retired"] { + assert!( + !registry.contains(polling), + "registry retirement must not use scheduler polling through {polling}" + ); + } + let Some(release_position) = registry.find("self.release_admission();") else { + panic!("registry retirement must release admission"); + }; + let Some(notification_position) = registry.find("self.cleanup.emit();") else { + panic!("registry retirement must emit cleanup notification"); + }; + assert!( + release_position < notification_position, + "admission must release before cleanup notification" + ); + + let session_tests = read(&crate_root("gateway-stt").join("tests/it/realtime_session.rs")); + let retirement_test = session_tests + .split_once("async fn dropping_session_retains_admission_until_interim_cleanup_joins()") + .and_then(|(_, rest)| rest.split_once("#[tokio::test]")) + .map_or_else( + || panic!("Realtime retirement regression must remain focused"), + |(body, _)| body, + ); + for evidence in ["cleanup_notified()", "tokio::time::timeout"] { + assert!( + retirement_test.contains(evidence), + "Realtime retirement regression must retain {evidence}" + ); + } + assert!( + !retirement_test.contains("wait_until(") + && !retirement_test.contains("tokio::task::yield_now"), + "Realtime retirement verification must not count scheduler yields" + ); + + for regression in [ + "cleanup_event_count()", + "dropping_session_retains_admission_until_finalization_cleanup_joins", + "retired_task_join_failures_are_preserved", + ] { + assert!( + session_tests.contains(regression), + "Realtime retirement regression must retain {regression}" + ); + } +} + +fn blank_rust_non_code(masked: &mut [u8], start: usize, end: usize) { + for byte in &mut masked[start..end] { + if !matches!(*byte, b'\n' | b'\r') { + *byte = b' '; + } + } +} + +fn rust_raw_string_end(source: &[u8], start: usize) -> Option { + let mut cursor = start; + if source.get(cursor) == Some(&b'b') { + cursor += 1; + } + if source.get(cursor) != Some(&b'r') { + return None; + } + cursor += 1; + let hashes_start = cursor; + while source.get(cursor) == Some(&b'#') { + cursor += 1; + } + let hashes = cursor - hashes_start; + if source.get(cursor) != Some(&b'"') { + return None; + } + cursor += 1; + while cursor < source.len() { + if source[cursor] == b'"' + && source.get(cursor + 1..cursor + 1 + hashes) + == Some(&source[hashes_start..hashes_start + hashes]) + { + return Some(cursor + 1 + hashes); + } + cursor += 1; + } + Some(source.len()) +} + +fn rust_quoted_end(source: &[u8], start: usize, quote: u8) -> usize { + let mut cursor = start + 1; + while cursor < source.len() { + match source[cursor] { + b'\\' => cursor = (cursor + 2).min(source.len()), + byte if byte == quote => return cursor + 1, + _ => cursor += 1, + } + } + source.len() +} + +fn rust_char_literal_end(source: &str, start: usize) -> Option { + let bytes = source.as_bytes(); + let mut cursor = start + 1; + match *bytes.get(cursor)? { + b'\\' => { + cursor += 1; + match *bytes.get(cursor)? { + b'x' => cursor += 3, + b'u' if bytes.get(cursor + 1) == Some(&b'{') => { + cursor += 2; + while bytes.get(cursor) != Some(&b'}') { + cursor += 1; + if cursor >= bytes.len() { + return None; + } + } + cursor += 1; + } + _ => cursor += 1, + } + } + _ => { + cursor += source[cursor..].chars().next()?.len_utf8(); + } + } + (bytes.get(cursor) == Some(&b'\'')).then_some(cursor + 1) +} + +fn mask_rust_non_code(source: &str) -> String { + let bytes = source.as_bytes(); + let mut masked = bytes.to_vec(); + let mut cursor = 0; + while cursor < bytes.len() { + if bytes.get(cursor..cursor + 2) == Some(b"//") { + let start = cursor; + cursor += 2; + while !matches!(bytes.get(cursor), None | Some(b'\n')) { + cursor += 1; + } + blank_rust_non_code(&mut masked, start, cursor); + } else if bytes.get(cursor..cursor + 2) == Some(b"/*") { + let start = cursor; + cursor += 2; + let mut depth = 1_usize; + while cursor < bytes.len() && depth > 0 { + if bytes.get(cursor..cursor + 2) == Some(b"/*") { + depth += 1; + cursor += 2; + } else if bytes.get(cursor..cursor + 2) == Some(b"*/") { + depth -= 1; + cursor += 2; + } else { + cursor += 1; + } + } + blank_rust_non_code(&mut masked, start, cursor); + } else if let Some(end) = rust_raw_string_end(bytes, cursor) { + blank_rust_non_code(&mut masked, cursor, end); + cursor = end; + } else if bytes[cursor] == b'"' { + let end = rust_quoted_end(bytes, cursor, b'"'); + blank_rust_non_code(&mut masked, cursor, end); + cursor = end; + } else if bytes[cursor] == b'\'' { + if let Some(end) = rust_char_literal_end(source, cursor) { + blank_rust_non_code(&mut masked, cursor, end); + cursor = end; + } else { + cursor += 1; + } + } else { + cursor += 1; + } + } + String::from_utf8(masked).unwrap_or_else(|error| panic!("masking must preserve UTF-8: {error}")) +} + +fn compact_rust_code(source: &str) -> String { + mask_rust_non_code(source) + .chars() + .filter(|character| !character.is_whitespace()) + .collect() +} + +fn production_rust_code(source: &str) -> String { + let mut code = compact_rust_code(source); + if let Some(tests) = code.rfind("#[cfg(test)]modtests{") { + code.truncate(tests); + } + code +} + +fn matching_delimiter(source: &str, open: usize, opening: u8, closing: u8) -> Option { + let mut depth = 0_usize; + for (offset, byte) in source.as_bytes()[open..].iter().enumerate() { + if *byte == opening { + depth += 1; + } else if *byte == closing { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(open + offset); + } + } + } + None +} + +fn braced_body_after<'a>(source: &'a str, marker: &str) -> Result<&'a str, String> { + let marker = source + .find(marker) + .ok_or_else(|| format!("missing `{marker}`"))?; + let open = source[marker..] + .find('{') + .map(|offset| marker + offset) + .ok_or_else(|| format!("`{marker}` has no body"))?; + let close = matching_delimiter(source, open, b'{', b'}') + .ok_or_else(|| format!("`{marker}` has an unbalanced body"))?; + Ok(&source[open + 1..close]) +} + +fn split_top_level(source: &str, delimiter: u8) -> Vec<&str> { + let mut parts = Vec::new(); + let mut start = 0; + let mut round = 0_usize; + let mut square = 0_usize; + let mut curly = 0_usize; + let mut angle = 0_usize; + for (index, byte) in source.bytes().enumerate() { + match byte { + b'(' => round += 1, + b')' => round = round.saturating_sub(1), + b'[' => square += 1, + b']' => square = square.saturating_sub(1), + b'{' => curly += 1, + b'}' => curly = curly.saturating_sub(1), + b'<' => angle += 1, + b'>' => angle = angle.saturating_sub(1), + byte if byte == delimiter && round == 0 && square == 0 && curly == 0 && angle == 0 => { + parts.push(&source[start..index]); + start = index + 1; + } + _ => {} + } + } + if start < source.len() { + parts.push(&source[start..]); + } + parts +} + +fn strip_attributes(mut field: &str) -> Result<&str, String> { + while field.starts_with("#[") { + let close = matching_delimiter(field, 1, b'[', b']') + .ok_or_else(|| format!("unbalanced field attribute in `{field}`"))?; + field = &field[close + 1..]; + } + Ok(field) +} + +fn struct_field_types(source: &str, name: &str) -> Result, String> { + let body = braced_body_after(source, &format!("struct{name}"))?; + split_top_level(body, b',') + .into_iter() + .filter(|field| !field.is_empty()) + .map(|field| { + let field = strip_attributes(field)?; + let colon = field + .find(':') + .ok_or_else(|| format!("`{name}` field `{field}` has no type"))?; + Ok(field[colon + 1..].to_owned()) + }) + .collect() +} + +const TRANSACTION_RESOURCE_TYPES: [&str; 13] = [ + "AppState", + "ProfileName", + "ProgressTree", + "SwitchTarget", + "StopSet", + "PreparedPersistence", + "PriorRuntimeSnapshot", + "StagedTarget", + "RuntimeReplacement", + "CancellationToken", + "Routing", + "StartReport", + "GatewayError", +]; + +fn require_exact_resources(source: &str, owner: &str, expected: &[&str]) -> Result<(), String> { + let fields = struct_field_types(source, owner)?; + let actual = fields + .into_iter() + .filter(|field| TRANSACTION_RESOURCE_TYPES.contains(&field.as_str())) + .collect::>(); + let expected = expected + .iter() + .copied() + .map(str::to_owned) + .collect::>(); + if actual == expected { + Ok(()) + } else { + Err(format!( + "{owner} transaction ownership must be exactly {expected:?}, got {actual:?}" + )) + } +} + +fn method_body<'a>(source: &'a str, owner: &str, signature: &str) -> Result<&'a str, String> { + let implementation = braced_body_after(source, &format!("impl{owner}"))?; + braced_body_after(implementation, signature) +} + +fn require_order(source: &str, markers: &[&str], invariant: &str) -> Result<(), String> { + let mut cursor = 0; + for marker in markers { + let position = source[cursor..] + .find(marker) + .ok_or_else(|| format!("{invariant} must retain ordered `{marker}`"))?; + cursor += position + marker.len(); + } + Ok(()) +} + +fn is_single_awaited_call(body: &str, callee: &str) -> bool { + let prefix = format!("{callee}("); + if !body.starts_with(&prefix) { + return false; + } + let open = prefix.len() - 1; + matching_delimiter(body, open, b'(', b')') + .is_some_and(|close| body.get(close + 1..) == Some(".await")) +} + +fn validate_transaction_phase_ownership(profile: &str) -> Result<(), String> { + for (owner, resources) in [ + ( + "PreparedPhase", + &[ + "AppState", + "ProfileName", + "ProgressTree", + "SwitchTarget", + "StopSet", + "PreparedPersistence", + "CancellationToken", + ][..], + ), + ( + "CutoverPhase", + &[ + "AppState", + "ProfileName", + "ProgressTree", + "SwitchTarget", + "PreparedPersistence", + "PriorRuntimeSnapshot", + "CancellationToken", + ], + ), + ( + "CutoverOwner", + &[ + "AppState", + "ProfileName", + "StagedTarget", + "PreparedPersistence", + "PriorRuntimeSnapshot", + "CancellationToken", + ], + ), + ( + "StagedPhase", + &[ + "AppState", + "ProfileName", + "StagedTarget", + "RuntimeReplacement", + "PreparedPersistence", + "PriorRuntimeSnapshot", + "CancellationToken", + ], + ), + ( + "CommitTail", + &[ + "AppState", + "ProfileName", + "StagedTarget", + "RuntimeReplacement", + "PriorRuntimeSnapshot", + "CancellationToken", + ], + ), + ( + "PublicationPhase", + &[ + "AppState", + "ProfileName", + "StagedTarget", + "RuntimeReplacement", + "CancellationToken", + "Routing", + ], + ), + ] { + require_exact_resources(profile, owner, resources)?; + } + Ok(()) +} + +fn validate_terminal_ownership(profile: &str) -> Result<(), String> { + for (owner, resources) in [ + ("CommittedPhase", &["StartReport"][..]), + ("RolledBackPhase", &["GatewayError"]), + ("IndeterminatePhase", &["GatewayError"]), + ( + "RollbackOwner", + &[ + "AppState", + "PriorRuntimeSnapshot", + "CancellationToken", + "GatewayError", + ], + ), + ] { + require_exact_resources(profile, owner, resources)?; + } + let terminal = braced_body_after(profile, "enumTerminalPhase")?; + if terminal + != "Committed(CommittedPhase),RolledBack(RolledBackPhase),Indeterminate(IndeterminatePhase)," + { + return Err(format!( + "TerminalPhase must exactly own committed, rolled-back, and indeterminate outcomes, \ + got `{terminal}`" + )); + } + Ok(()) +} + +fn validate_preparation_and_staging(profile: &str) -> Result<(), String> { + let cutover = method_body( + profile, + "PreparedPhase", + "asyncfncut_over(self)->Result", + )?; + require_order( + cutover, + &[ + "capture_runtime_snapshot(&self.state).await", + "cut_over(&self.state,&self.target,&self.tree,self.stop,&self.token,).await", + "self.roll_back(prior,error).await", + "CutoverPhase{", + ], + "prepared-to-cutover transition", + )?; + + let stage = method_body( + profile, + "CutoverPhase", + "asyncfnstage(self)->Result", + )?; + require_order( + stage, + &[ + "ifself.token.is_cancelled(){", + "letSome(deadline)=", + "letowner=CutoverOwner{", + "spawn_runtimes(", + "letstaged=owner.into_staged(replacement);", + "ifstaged.token.is_cancelled(){", + "staged.roll_back_after_stage(switch_cancelled).await", + ], + "cutover-to-staged cancellation and ownership", + )?; + + let prepare = braced_body_after(profile, "pub(super)asyncfnprepare(")?; + require_order( + prepare, + &[ + "iftoken.is_cancelled(){", + "prepare_target(", + "iftoken.is_cancelled(){", + "download_artifacts(", + "iftoken.is_cancelled(){", + "PreparedPersistence::prepare(", + "iftoken.is_cancelled(){", + "Ok(PreparedPhase{", + ], + "preparation cancellation and persistence ownership", + ) +} + +fn validate_commit_and_publication(profile: &str) -> Result<(), String> { + let commit = method_body(profile, "StagedPhase", "asyncfncommit(self)->TerminalPhase")?; + require_order( + commit, + &[ + "ifself.token.is_cancelled(){", + "letpublication_state=state.clone();", + "()=self.token.cancelled()=>", + "ifself.token.is_cancelled(){", + "letStagedPhase{", + "matchpersistence.commit().await{", + "PersistenceCommitError::Determinate(error)", + "tail.into_rollback(error)", + "PersistenceCommitError::Indeterminate(error)", + "tail.into_indeterminate(", + "letpublication=tail.into_publication(routing);", + "publication.publish().await", + ], + "persistence-before-publication and commit cancellation", + )?; + if commit.matches("into_publication(").count() != 1 { + return Err( + "persistence-before-publication requires one consuming publication transition" + .to_owned(), + ); + } + + method_body( + profile, + "CommitTail", + "fninto_publication(self,routing:Routing)->PublicationPhase", + )?; + method_body( + profile, + "PublicationPhase", + "asyncfnpublish(self)->TerminalPhase", + )?; + method_body( + profile, + "TerminalPhase", + "fnfinish(self)->Result", + )?; + Ok(()) +} + +fn validate_rollback_reconstruction(profile: &str, generation: &str) -> Result<(), String> { + let restore = braced_body_after(profile, "asyncfnrestore_runtime_snapshot(")?; + require_order( + restore, + &[ + "LocalRuntime::start(", + "state.live.write().await", + "live.routing=prior.routing", + "live.config=prior.config", + "live.profile_name=prior.profile_name", + "live.model_allowlist=prior.model_allowlist", + "live.loading=prior.loading", + ], + "prior runtime reconstruction before republication", + )?; + + let rollback = method_body( + profile, + "RollbackOwner", + "asyncfnfinish(self)->TerminalPhase", + )?; + require_order( + rollback, + &[ + "restore_runtime_snapshot(&self.state,self.prior).await", + "request_fatal_shutdown(", + ], + "rollback reconstruction failure escalation", + )?; + let runtime_rollback = braced_body_after(profile, "fnrollback_runtime(")?; + if !runtime_rollback.contains("abort_replacement(replacement.speech)") { + return Err("staged rollback must reconstruct the retired speech generation".to_owned()); + } + + let replacement = struct_field_types(generation, "SpeechReplacement")?; + for owned in [ + "Weak", + "Option", + "Option", + "ReplacementPermit", + ] { + if !replacement.contains(owned) { + return Err(format!( + "SpeechReplacement must own restartable rollback resource `{owned}`" + )); + } + } + let speech_rollback = method_body( + generation, + "SpeechReplacement", + "fnrollback(&mutself)->Result<(),SpeechError>", + )?; + if !speech_rollback.contains("restore_generation(&owner,&self.permit,&rollback)") { + return Err("speech rollback must reconstruct its retired generation".to_owned()); + } + let speech_drop = method_body(generation, "DropforSpeechReplacement", "fndrop(&mutself)")?; + if !speech_drop.contains("self.rollback()") { + return Err("dropped speech replacement must roll back its owned generation".to_owned()); + } + Ok(()) +} + +fn validate_fatal_indeterminate_shutdown(profile: &str) -> Result<(), String> { + let fatal = braced_body_after(profile, "pub(super)fnrequest_fatal_shutdown(")?; + require_order( + fatal, + &[ + "token.cancel();", + "state.shutdown.fire();", + "state.speech.shutdown();", + "GatewayError::switch_failed(", + ], + "fatal indeterminate shutdown", + )?; + + let mut cursor = 0; + let mut constructors = 0; + while let Some(relative) = profile[cursor..].find("IndeterminatePhase{") { + let start = cursor + relative; + let open = start + "IndeterminatePhase".len(); + cursor = open + 1; + if profile[..start].ends_with("struct") { + continue; + } + let close = matching_delimiter(profile, open, b'{', b'}') + .ok_or_else(|| "indeterminate phase construction must be balanced".to_owned())?; + if !profile[open + 1..close].starts_with("error:request_fatal_shutdown(") { + return Err( + "every indeterminate outcome must be constructed through fatal shutdown".to_owned(), + ); + } + constructors += 1; + } + if constructors == 0 { + return Err("transaction must construct fatal indeterminate outcomes".to_owned()); + } + Ok(()) +} + +fn validate_root_transaction_delegation(root: &str) -> Result<(), String> { + let root_delegate = braced_body_after(root, "asyncfnrun_switch_with_config(")?; + if !is_single_awaited_call(root_delegate, "profile_switch::run") { + return Err( + "Gateway root must delegate profile switching as one awaited transaction call" + .to_owned(), + ); + } + Ok(()) +} + +fn validate_profile_replacement_architecture( + profile_switch: &str, + gateway_root: &str, + speech_generation: &str, +) -> Result<(), String> { + let profile = production_rust_code(profile_switch); + let root = production_rust_code(gateway_root); + let generation = production_rust_code(speech_generation); + validate_transaction_phase_ownership(&profile)?; + validate_terminal_ownership(&profile)?; + validate_preparation_and_staging(&profile)?; + validate_commit_and_publication(&profile)?; + validate_rollback_reconstruction(&profile, &generation)?; + validate_fatal_indeterminate_shutdown(&profile)?; + validate_root_transaction_delegation(&root) +} + +#[test] +fn profile_replacement_policy_requires_restartable_rollback_and_fatal_shutdown() { + let generation = read(&crate_root("gateway-stt").join("src/generation.rs")); + let gateway = read(&crate_root("gateway").join("src/lib.rs")); + let profile_switch = read(&crate_root("gateway").join("src/profile_switch.rs")); + + validate_profile_replacement_architecture(&profile_switch, &gateway, &generation) + .unwrap_or_else(|error| panic!("{error}")); +} + +#[test] +fn profile_replacement_architecture_rejects_adversarial_mutations() { + let generation = read(&crate_root("gateway-stt").join("src/generation.rs")); + let gateway = read(&crate_root("gateway").join("src/lib.rs")); + let profile_switch = read(&crate_root("gateway").join("src/profile_switch.rs")); + let mutation = |source: &str, from: &str, to: &str| { + assert!( + source.contains(from), + "mutation fixture must contain `{from}`" + ); + source.replacen(from, to, 1) + }; + + let ownership = mutation( + &profile_switch, + " persistence: PreparedPersistence,\n", + " persistence: Arc,\n", + ); + assert!( + validate_profile_replacement_architecture(&ownership, &gateway, &generation) + .is_err_and(|error| error.contains("PreparedPhase transaction ownership")) + ); + + let early_publication = mutation( + &profile_switch, + " match persistence.commit().await {", + " let _premature = tail.into_publication(routing);\n\ + match persistence.commit().await {", + ); + assert!( + validate_profile_replacement_architecture(&early_publication, &gateway, &generation) + .is_err_and(|error| error.contains("persistence-before-publication")) + ); + + let cancelled_boundary = mutation( + &profile_switch, + " if self.token.is_cancelled() {\n let error = switch_cancelled(&self.name);", + " if false {\n let error = switch_cancelled(&self.name);", + ); + assert!( + validate_profile_replacement_architecture(&cancelled_boundary, &gateway, &generation) + .is_err_and(|error| error.contains("cutover-to-staged cancellation")) + ); + + let nonfatal = mutation( + &profile_switch, + " state.shutdown.fire();", + " // state.shutdown.fire();", + ); + assert!( + validate_profile_replacement_architecture(&nonfatal, &gateway, &generation) + .is_err_and(|error| error.contains("fatal indeterminate shutdown")) + ); + + let unowned_terminal = mutation( + &profile_switch, + " Indeterminate(IndeterminatePhase),", + " Indeterminate(GatewayError),", + ); + assert!( + validate_profile_replacement_architecture(&unowned_terminal, &gateway, &generation) + .is_err_and(|error| error.contains("TerminalPhase must exactly own")) + ); + + let root_orchestration = mutation( + &gateway, + " profile_switch::run(&state, name, tree, candidate, persistence, token).await", + " let _duplicate_owner = &state;\n\ + profile_switch::run(&state, name, tree, candidate, persistence, token).await", + ); + assert!( + validate_profile_replacement_architecture( + &profile_switch, + &root_orchestration, + &generation + ) + .is_err_and(|error| error.contains("Gateway root must delegate")) + ); + + let dropped_reconstruction = mutation( + &generation, + "restore_generation(&owner, &self.permit, &rollback)", + "Ok(())", + ); + assert!( + validate_profile_replacement_architecture( + &profile_switch, + &gateway, + &dropped_reconstruction + ) + .is_err_and(|error| error.contains("speech rollback must reconstruct")) + ); +} + +#[test] +fn gateway_speech_discovery_uses_only_generic_facade_facts() { + let gateway_root = crate_root("gateway").join("src"); + let gateway = read(&gateway_root.join("lib.rs")); + let section = |start, end| { + gateway + .split_once(start) + .and_then(|(_, rest)| rest.split_once(end)) + .map_or_else( + || panic!("Gateway source must retain `{start}` before `{end}`"), + |(body, _)| body, + ) + }; + let model_info = read(&gateway_root.join("model_info.rs")); + let system = read(&gateway_root.join("system.rs")); + let discovery = [ + section("async fn list_models(", "#[derive(Debug, Deserialize)]"), + section("async fn admin_status(", "async fn admin_queue_cancel("), + model_info.as_str(), + system.as_str(), + ] + .concat(); + let compact = discovery + .chars() + .filter(|character| !character.is_whitespace()) + .collect::(); + + for required in ["speech.status()", "speech.models()"] { + assert!( + compact.contains(required), + "Gateway speech discovery must use facade fact `{required}`" + ); + } + for forbidden in [ + "stt_models()", + "SttRole", + "WorkshopSttConfig", + "workshop_status", + ] { + assert!( + !compact.contains(forbidden), + "Gateway speech discovery must not depend on `{forbidden}`" + ); + } +} + +#[test] +fn compiler_unsafe_lints_cover_the_stt_stack() { + let workspace: toml::Value = toml::from_str(&read(&workspace_root().join("Cargo.toml"))) + .unwrap_or_else(|error| panic!("workspace Cargo.toml must parse: {error}")); + assert_eq!( + workspace["workspace"]["lints"]["rust"]["unsafe_code"].as_str(), + Some("forbid"), + "the workspace compiler lint must forbid unsafe code" + ); + + for crate_name in [ + "gateway-stt", + "gateway-stt-engine", + "gateway-stt-backend-whisper", + ] { + let manifest: toml::Value = + toml::from_str(&read(&crate_root(crate_name).join("Cargo.toml"))) + .unwrap_or_else(|error| panic!("{crate_name} Cargo.toml must parse: {error}")); + assert_eq!( + manifest["lints"]["workspace"].as_bool(), + Some(true), + "{crate_name} must inherit the workspace unsafe compiler lint" + ); + } + + let ffi: toml::Value = + toml::from_str(&read(&crate_root("gateway-whisper-ffi").join("Cargo.toml"))) + .unwrap_or_else(|error| panic!("gateway-whisper-ffi Cargo.toml must parse: {error}")); + assert_eq!( + ffi["lints"]["rust"]["unsafe_code"].as_str(), + Some("deny"), + "the FFI leaf must deny unsafe code outside its explicit expectations" + ); +} diff --git a/crates/gateway-stt/tests/it/batch.rs b/crates/gateway-stt/tests/it/batch.rs new file mode 100644 index 00000000..dcbc405c --- /dev/null +++ b/crates/gateway-stt/tests/it/batch.rs @@ -0,0 +1,53 @@ +//! Characterization tests for physical-model batch transcription. + +use axum::http::StatusCode; + +use crate::common::{ + copy_model_replacing_token, fixture_service_with_models, jfk_samples, require_model, + transcribe_batch, +}; + +#[tokio::test] +#[ignore = "requires whisper test fixtures (tests/fixtures/)"] +async fn batch_selects_each_loaded_physical_model_by_name() { + let interim_model = require_model(); + let fixture_dir = tempfile::tempdir().expect("distinct model tempdir"); + let final_model = + copy_model_replacing_token(&interim_model, fixture_dir.path(), b"country", b"kingdom"); + let service = fixture_service_with_models(&interim_model, Some(final_model.as_path())); + let samples = jfk_samples(); + + let (interim_status, interim_response) = + transcribe_batch(service.clone(), "speech", &samples).await; + assert_eq!( + interim_status, + StatusCode::OK, + "the interim physical model is directly selectable" + ); + let interim_text = interim_response["text"] + .as_str() + .expect("interim batch response text is a string") + .to_lowercase(); + assert!( + interim_text.contains("country") && !interim_text.contains("kingdom"), + "speech reaches the unmodified interim worker: {interim_text:?}" + ); + + let (final_status, final_response) = + transcribe_batch(service.clone(), "speech-final", &samples).await; + assert_eq!( + final_status, + StatusCode::OK, + "the final physical model is directly selectable" + ); + let final_text = final_response["text"] + .as_str() + .expect("final batch response text is a string") + .to_lowercase(); + assert!( + final_text.contains("kingdom") && !final_text.contains("country"), + "speech-final reaches the vocabulary-distinguished final worker: {final_text:?}" + ); + + service.shutdown(); +} diff --git a/crates/gateway-stt/tests/it/generation.rs b/crates/gateway-stt/tests/it/generation.rs new file mode 100644 index 00000000..eb464c7f --- /dev/null +++ b/crates/gateway-stt/tests/it/generation.rs @@ -0,0 +1,473 @@ +//! Generation replacement and ownership integration tests. + +#![expect( + clippy::expect_used, + reason = "integration tests panic with the failed ownership invariant" +)] + +use std::sync::{Arc, Barrier, mpsc}; +use std::time::Duration; + +use gateway_stt::SpeechService; +use gateway_stt::test_fixtures::{ + ScriptedDecoder, ScriptedModelFactory, begin_scripted_replacement, generation_counts, + generation_ownership, scripted_service, +}; + +use crate::common::transcribe_batch; + +const WAIT: Duration = Duration::from_secs(2); + +fn factory(decoder: &ScriptedDecoder) -> ScriptedModelFactory { + ScriptedModelFactory::new(decoder.clone()) +} + +fn service(decoder: &ScriptedDecoder) -> SpeechService { + scripted_service(factory(decoder), 15, 500).expect("scripted generation starts") +} + +#[test] +fn request_and_worker_job_ownership_are_counted_independently() { + let decoder = ScriptedDecoder::new(); + let service = service(&decoder); + let request = generation_ownership(&service).expect("open generation admits a request"); + assert_eq!(generation_counts(&service), Some((1, 0))); + + let job = request + .own_worker_job() + .expect("the admitted request owns a worker job"); + assert_eq!(generation_counts(&service), Some((1, 1))); + + drop(request); + assert_eq!( + generation_counts(&service), + Some((0, 1)), + "request cancellation cannot report false worker idleness" + ); + drop(job); + assert_eq!(generation_counts(&service), Some((0, 0))); + service.shutdown(); +} + +#[test] +fn a_quiescence_deadline_reopens_the_same_snapshot_with_a_fresh_epoch() { + let old = ScriptedDecoder::new(); + let service = service(&old); + let stale = generation_ownership(&service).expect("old generation admits"); + let stale_epoch = stale.epoch(); + let replacement = ScriptedDecoder::new(); + + let error = begin_scripted_replacement( + &service, + factory(&replacement), + false, + Duration::from_millis(20), + ) + .expect_err("owned old request prevents bounded quiescence"); + + assert!(error.to_string().contains("quiescence deadline")); + assert!(stale.is_replaced(), "closing cancels the old session epoch"); + assert!( + replacement.creation_thread().is_none(), + "a failed drain never loads replacement model memory" + ); + let fresh = generation_ownership(&service).expect("deadline reopens admission"); + assert_ne!(fresh.epoch(), stale_epoch); + assert!(!fresh.is_replaced()); + assert!(service.status().ready()); + + drop((fresh, stale)); + service.shutdown(); +} + +#[test] +fn an_unrepresentable_deadline_leaves_the_same_snapshot_open() { + let old = ScriptedDecoder::new(); + let service = service(&old); + let admitted = generation_ownership(&service).expect("old generation admits"); + let epoch = admitted.epoch(); + let replacement = ScriptedDecoder::new(); + + let error = begin_scripted_replacement(&service, factory(&replacement), false, Duration::MAX) + .expect_err("an unrepresentable deadline rejects replacement"); + + assert!(error.to_string().contains("quiescence deadline")); + assert!( + !admitted.is_replaced(), + "deadline validation happens before admission or epoch mutation" + ); + assert!( + replacement.creation_thread().is_none(), + "invalid control-plane input never loads replacement model memory" + ); + let fresh = generation_ownership(&service).expect("the original snapshot remains open"); + assert_eq!(fresh.epoch(), epoch); + assert!(!fresh.is_replaced()); + assert!(service.status().ready()); + + drop((fresh, admitted)); + service.shutdown(); +} + +#[test] +fn replacement_is_serial_and_publishes_one_complete_snapshot() { + let service = SpeechService::new(); + let first_decoder = ScriptedDecoder::new(); + let first = begin_scripted_replacement(&service, factory(&first_decoder), false, WAIT) + .expect("first stages"); + let second_interim = ScriptedDecoder::new(); + let second_final = ScriptedDecoder::new(); + let second_factory = factory(&second_interim) + .with_final(second_final.clone()) + .with_gpu_available(true); + let contender_service = service.clone(); + let (finished_tx, finished_rx) = mpsc::channel(); + let contender = std::thread::spawn(move || { + drop(finished_tx.send(begin_scripted_replacement( + &contender_service, + second_factory, + true, + WAIT, + ))); + }); + + assert!( + matches!( + finished_rx.recv_timeout(Duration::from_millis(50)), + Err(mpsc::RecvTimeoutError::Timeout) + ), + "a second replacement waits for ownership of the first transaction" + ); + assert!(second_interim.creation_thread().is_none()); + + service + .abort_replacement(first) + .expect("aborting the first replacement leaves no old generation"); + let second = finished_rx + .recv_timeout(WAIT) + .expect("second replacement resumes") + .expect("second replacement stages"); + contender.join().expect("replacement contender joins"); + service + .commit_replacement(second) + .expect("complete generation publishes"); + + let status = service.status(); + assert!(status.ready()); + assert!(status.gpu()); + assert_eq!( + service + .models() + .iter() + .map(gateway_stt::SpeechModelInfo::name) + .collect::>(), + ["scripted-interim", "scripted-final", "realtime-transcribe"] + ); + assert!(first_decoder.worker_dropped()); + service.shutdown(); + assert!(second_interim.worker_dropped()); + assert!(second_final.worker_dropped()); +} + +#[tokio::test] +async fn active_replacement_drains_request_and_job_before_unload_and_publication() { + let old = ScriptedDecoder::new(); + let service = service(&old); + let next_interim = ScriptedDecoder::new(); + let next_final = ScriptedDecoder::new(); + let next_factory = factory(&next_interim) + .with_final(next_final.clone()) + .with_gpu_available(true); + let replacement = old + .with_next_decode_blocked( + WAIT, + || { + let request_service = service.clone(); + async move { + (tokio::spawn(async move { + transcribe_batch(request_service, "scripted-interim", &[0.25; 16]).await + }),) + } + }, + |(request,)| async { + assert_eq!(generation_counts(&service), Some((1, 1))); + let replacement_service = service.clone(); + let replacement = tokio::task::spawn_blocking(move || { + begin_scripted_replacement(&replacement_service, next_factory, true, WAIT) + }); + + tokio::time::timeout(WAIT, async { + while generation_counts(&service) != Some((0, 1)) { + tokio::task::yield_now().await; + } + }) + .await + .expect("request ownership drains while the parked job remains"); + tokio::time::timeout(WAIT, request) + .await + .expect("canceled old request returns") + .expect("old request task joins"); + let draining = service.status(); + assert!( + draining.configured(), + "draining keeps the published configuration" + ); + assert!(!draining.ready(), "closed admission is not ready"); + assert_eq!( + draining.generation(), + None, + "draining never exposes a generation that refuses admission" + ); + assert!( + service.models().is_empty(), + "draining publishes no discoverable speech model" + ); + assert!( + next_interim.creation_thread().is_none() + && next_final.creation_thread().is_none(), + "replacement construction waits for every old worker job" + ); + assert!( + !old.worker_dropped(), + "the running old worker remains owned until native work returns" + ); + (replacement,) + }, + ) + .await + .expect("the old generation owns one blocked native-equivalent job"); + let (replacement,) = replacement; + let replacement = tokio::time::timeout(WAIT, replacement) + .await + .expect("active replacement finishes after old work drains") + .expect("replacement task joins") + .expect("replacement stages"); + assert!( + old.worker_dropped(), + "old workers unload before the staged replacement returns" + ); + assert!(next_interim.creation_thread().is_some()); + assert!(next_final.creation_thread().is_some()); + + service + .commit_replacement(replacement) + .expect("the complete replacement publishes"); + assert_eq!(generation_counts(&service), Some((0, 0))); + assert!(service.status().ready()); + assert!(service.status().gpu()); + assert_eq!( + service + .models() + .iter() + .map(gateway_stt::SpeechModelInfo::name) + .collect::>(), + ["scripted-interim", "scripted-final", "realtime-transcribe"] + ); + + service.shutdown(); + assert!(next_interim.worker_dropped()); + assert!(next_final.worker_dropped()); +} + +#[test] +fn aborting_a_staged_replacement_reconstructs_the_old_generation() { + let old = ScriptedDecoder::new(); + let service = service(&old); + let old_generation = service.status().generation(); + let next = ScriptedDecoder::new(); + let replacement = begin_scripted_replacement(&service, factory(&next), false, WAIT) + .expect("replacement stages"); + + assert!(!service.status().ready(), "staged state stays unpublished"); + service + .abort_replacement(replacement) + .expect("determinate abort reconstructs the old specification"); + + let restored = service.status(); + assert!(restored.ready()); + assert_ne!( + restored.generation(), + old_generation, + "reconstruction publishes a fresh generation" + ); + let request = generation_ownership(&service).expect("reconstructed generation admits"); + assert!( + request.own_worker_job().is_some(), + "the reconstructed generation accepts worker ownership" + ); + drop(request); + assert!(next.worker_dropped(), "the staged worker is joined"); + service.shutdown(); +} + +#[test] +fn determinate_start_failure_reconstructs_the_old_generation() { + let old = ScriptedDecoder::new(); + let service = service(&old); + let failed = ScriptedDecoder::new(); + + let error = begin_scripted_replacement( + &service, + factory(&failed).with_interim_failure("determinate startup failure"), + false, + WAIT, + ) + .expect_err("replacement construction fails"); + + assert!( + format!("{error:?}").contains("determinate startup failure"), + "the original determinate failure remains visible: {error:?}" + ); + assert!( + service.status().ready(), + "a determinate staged failure reconstructs old speech" + ); + assert!( + generation_ownership(&service) + .and_then(|request| request.own_worker_job()) + .is_some(), + "the old specification starts an admitting worker before failure returns" + ); + service.shutdown(); +} + +#[test] +fn determinate_start_failure_reports_failed_old_generation_reconstruction() { + let old = ScriptedDecoder::new(); + let service = service(&old); + old.fail_next_construction("rollback reconstruction sentinel"); + let failed = ScriptedDecoder::new(); + + let error = begin_scripted_replacement( + &service, + factory(&failed).with_interim_failure("determinate startup sentinel"), + false, + WAIT, + ) + .expect_err("both replacement and reconstruction fail"); + let debug = format!("{error:?}"); + + assert!(debug.contains("determinate startup sentinel"), "{debug}"); + assert!( + debug.contains("rollback reconstruction sentinel"), + "{debug}" + ); + assert!( + !service.status().ready(), + "failed reconstruction cannot claim speech remains available" + ); +} + +#[test] +fn rollback_attempts_reconstruction_after_staged_worker_shutdown_fails() { + let old = ScriptedDecoder::new(); + let service = service(&old); + let next = ScriptedDecoder::new(); + let replacement = begin_scripted_replacement(&service, factory(&next), false, WAIT) + .expect("replacement stages"); + next.panic_on_drop(); + old.fail_next_construction("reconstruction after cleanup sentinel"); + + let error = service + .abort_replacement(replacement) + .expect_err("cleanup and reconstruction failures are aggregated"); + let debug = format!("{error:?}"); + + assert!(debug.contains("ShutdownPanicked"), "{debug}"); + assert!( + debug.contains("reconstruction after cleanup sentinel"), + "{debug}" + ); + assert!(!service.status().ready()); +} + +#[tokio::test] +async fn canceled_request_keeps_its_worker_job_owned_until_decode_returns() { + let old = ScriptedDecoder::new(); + let service = service(&old); + let replacement = ScriptedDecoder::new(); + old.with_next_decode_blocked( + WAIT, + || { + let request_service = service.clone(); + async move { + (tokio::spawn(async move { + transcribe_batch(request_service, "scripted-interim", &[0.25; 16]).await + }),) + } + }, + |(request,)| async { + assert_eq!(generation_counts(&service), Some((1, 1))); + request.abort(); + assert!( + request + .await + .expect_err("request is canceled") + .is_cancelled() + ); + tokio::time::timeout(WAIT, async { + while generation_counts(&service) != Some((0, 1)) { + tokio::task::yield_now().await; + } + }) + .await + .expect("request ownership drops while worker ownership remains"); + assert_eq!(generation_counts(&service), Some((0, 1))); + + let replacement_control = replacement.clone(); + let replacement_service = service.clone(); + let attempt = tokio::task::spawn_blocking(move || { + begin_scripted_replacement( + &replacement_service, + factory(&replacement_control), + false, + Duration::from_millis(20), + ) + }) + .await + .expect("replacement attempt joins"); + let error = attempt.expect_err("the live worker job prevents quiescence"); + assert!(error.to_string().contains("quiescence deadline")); + assert!(replacement.creation_thread().is_none()); + }, + ) + .await + .expect("decode reaches the blocked native-equivalent scenario"); + tokio::time::timeout(WAIT, async { + while generation_counts(&service) != Some((0, 0)) { + tokio::task::yield_now().await; + } + }) + .await + .expect("worker ownership drains after native decode returns"); + service.shutdown(); +} + +#[test] +fn shutdown_wins_a_race_with_staged_publication() { + for _ in 0..8 { + let service = SpeechService::new(); + let decoder = ScriptedDecoder::new(); + let replacement = begin_scripted_replacement(&service, factory(&decoder), false, WAIT) + .expect("generation stages"); + let barrier = Arc::new(Barrier::new(2)); + let commit_barrier = Arc::clone(&barrier); + let commit_service = service.clone(); + let commit = std::thread::spawn(move || { + commit_barrier.wait(); + commit_service.commit_replacement(replacement) + }); + + barrier.wait(); + service.shutdown(); + let outcome = commit.join().expect("commit contender joins"); + if let Err(error) = outcome { + assert!(error.to_string().contains("invalidated")); + } + assert!( + !service.status().ready(), + "shutdown never permits a stale staged generation to survive" + ); + assert!(decoder.worker_dropped()); + } +} diff --git a/crates/gateway-stt/tests/it/main.rs b/crates/gateway-stt/tests/it/main.rs index 2f30d3cf..1da2d7cd 100644 --- a/crates/gateway-stt/tests/it/main.rs +++ b/crates/gateway-stt/tests/it/main.rs @@ -1,6 +1,18 @@ //! STT HTTP and WebSocket integration tests. +#[cfg(not(miri))] #[path = "../common/mod.rs"] mod common; -mod stt; +#[cfg(not(miri))] +mod architecture; +#[cfg(not(miri))] +mod batch; +#[cfg(not(miri))] +mod generation; +#[cfg(not(miri))] +mod realtime_fixtures; +#[cfg(not(miri))] +mod realtime_session; +#[cfg(not(miri))] +mod service; diff --git a/crates/gateway-stt/tests/it/realtime_fixtures.rs b/crates/gateway-stt/tests/it/realtime_fixtures.rs new file mode 100644 index 00000000..419259a1 --- /dev/null +++ b/crates/gateway-stt/tests/it/realtime_fixtures.rs @@ -0,0 +1,672 @@ +#![expect( + clippy::expect_used, + clippy::too_many_lines, + reason = "fixture characterization fails with the contract invariant named" +)] + +use std::collections::{BTreeSet, HashSet}; +use std::path::PathBuf; + +use serde_json::{Map, Value}; + +const FIXTURE_FILES: &[&str] = &[ + "client-events.json", + "effective-sessions.json", + "invalid-sequences.json", + "server-events.json", + "valid-sequences.json", +]; + +const CLIENT_CASES: &[&str] = &[ + "input_audio_buffer_append", + "input_audio_buffer_clear", + "input_audio_buffer_commit", + "session_update", +]; + +const SERVER_CASES: &[&str] = &[ + "conversation_item_created", + "error_correlated", + "error_minimal", + "error_uncorrelated", + "input_audio_buffer_cleared", + "input_audio_buffer_committed", + "session_created", + "session_updated", + "transcription_completed", + "transcription_delta", + "transcription_failed", + "transcription_hypothesis", +]; + +const VALID_SEQUENCE_CASES: &[&str] = &[ + "clear_retires_only_uncommitted_input", + "configuration_snapshot_isolation", + "durable_lineage", + "engine_replacement", + "first_event_readiness", + "hypothesis_negotiation", + "immediate_commit_and_provisional_promotion", + "optional_client_ids_and_error_correlation", + "overlapping_items_reverse_completion", + "pending_precommit_failure_clear", + "pending_precommit_failure_commit", + "producer_hypothesis_ownership", + "saturated_commit_retry", + "segment_admission_failure", + "standard_delta_after_item_creation", +]; + +const INVALID_SEQUENCE_CASES: &[&str] = &[ + "append_after_precommit_failure", + "append_invalid_base64", + "append_limit_exceeded", + "append_unknown_field", + "clear_unknown_field", + "commit_short_audio", + "commit_unknown_field", + "dangling_pcm_byte_on_commit", + "excessive_queue_lag", + "invalid_client_event_id", + "invalid_include_type", + "invalid_prompt_type", + "malformed_json", + "maximum_committed_items", + "maximum_unfinalized_audio", + "missing_append_audio", + "missing_client_event_type", + "missing_session", + "missing_session_type", + "non_null_noise_reduction", + "non_null_turn_detection", + "result_queue_overload", + "session_audio_unknown_field", + "session_input_unknown_field", + "session_transcription_unknown_field", + "session_unknown_field", + "session_update_unknown_field", + "unknown_event_type", + "unknown_include", + "unsupported_delay", + "unsupported_format_rate", + "unsupported_format_type", + "unsupported_keywords", + "unsupported_language", + "unsupported_logprobs", + "unsupported_model", + "wrong_session_type", +]; + +const MINIMUM_COMMIT_AUDIO_BYTES: usize = 24_000 * 2 / 10; + +fn fixture_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("realtime") +} + +fn fixture(name: &str) -> Value { + let bytes = std::fs::read(fixture_dir().join(name)).expect("canonical fixture reads"); + let parsed: Value = serde_json::from_slice(&bytes).expect("canonical fixture is JSON"); + let reparsed: Value = + serde_json::from_str(&serde_json::to_string(&parsed).expect("fixture serializes")) + .expect("serialized fixture parses"); + assert_eq!( + reparsed, parsed, + "{name} round-trips without semantic drift" + ); + parsed +} + +fn object<'a>(value: &'a Value, context: &str) -> &'a Map { + value + .as_object() + .unwrap_or_else(|| panic!("{context} is an object")) +} + +fn assert_exact_keys(object: &Map, expected: &[&str], context: &str) { + let actual = object.keys().map(String::as_str).collect::>(); + let expected = expected.iter().copied().collect::>(); + assert_eq!(actual, expected, "{context} has the strict field set"); +} + +fn assert_exact_cases(value: &Value, expected: &[&str], context: &str) { + assert_exact_keys(object(value, context), expected, context); +} + +fn assert_nonempty_string(value: Option<&Value>, context: &str) { + assert!( + value + .and_then(Value::as_str) + .is_some_and(|text| !text.is_empty()), + "{context} is a nonempty string" + ); +} + +fn assert_session(value: &Value, context: &str) { + let session = object(value, context); + assert_exact_keys( + session, + &["audio", "id", "include", "object", "type"], + context, + ); + assert_nonempty_string(session.get("id"), &format!("{context}.id")); + assert_eq!( + session.get("object").and_then(Value::as_str), + Some("realtime.transcription_session") + ); + assert_eq!( + session.get("type").and_then(Value::as_str), + Some("transcription") + ); + let include = session + .get("include") + .and_then(Value::as_array) + .expect("effective include is an array"); + assert!( + include.len() <= 1, + "effective include has at most one value" + ); + if let Some(value) = include.first() { + assert_eq!( + value.as_str(), + Some("item.input_audio_transcription.hypothesis") + ); + } + + let audio = object( + session.get("audio").expect("session has audio"), + "session.audio", + ); + assert_exact_keys(audio, &["input"], "session.audio"); + let input = object( + audio.get("input").expect("session has audio input"), + "session.audio.input", + ); + assert_exact_keys( + input, + &[ + "format", + "noise_reduction", + "transcription", + "turn_detection", + ], + "session.audio.input", + ); + assert!(input["noise_reduction"].is_null()); + assert!(input["turn_detection"].is_null()); + + let format = object(&input["format"], "session.audio.input.format"); + assert_exact_keys(format, &["rate", "type"], "session.audio.input.format"); + assert_eq!(format["type"], "audio/pcm"); + assert_eq!(format["rate"], 24_000); + + let transcription = object(&input["transcription"], "session.audio.input.transcription"); + assert_exact_keys( + transcription, + &["model", "prompt"], + "session.audio.input.transcription", + ); + assert_eq!(transcription["model"], "realtime-transcribe"); + assert!(transcription["prompt"].is_string()); +} + +#[derive(Clone, Copy)] +enum ErrorCorrelation<'a> { + Omitted, + Null, + Client(&'a str), +} + +fn assert_error(value: &Value, correlation: ErrorCorrelation<'_>, context: &str) { + let event = object(value, context); + assert_exact_keys(event, &["error", "event_id", "type"], context); + assert_nonempty_string(event.get("event_id"), &format!("{context}.event_id")); + assert_eq!(event["type"], "error"); + let error = object(&event["error"], &format!("{context}.error")); + let required = ["code", "message", "type"]; + assert!( + required.iter().all(|field| error.contains_key(*field)), + "{context}.error has every required field" + ); + assert!( + error.keys().all(|field| matches!( + field.as_str(), + "code" | "event_id" | "message" | "param" | "type" + )), + "{context}.error has no unsupported field" + ); + for field in ["type", "code", "message"] { + assert_nonempty_string(error.get(field), &format!("{context}.error.{field}")); + } + if let Some(param) = error.get("param") { + assert!(param.is_null() || param.is_string()); + } + match correlation { + ErrorCorrelation::Omitted => assert!(!error.contains_key("event_id")), + ErrorCorrelation::Null => assert!(error["event_id"].is_null()), + ErrorCorrelation::Client(expected) => assert_eq!(error["event_id"], expected), + } +} + +fn assert_wire_event(value: &Value, direction: &str, context: &str) { + let event = object(value, context); + assert_nonempty_string(event.get("type"), &format!("{context}.type")); + match direction { + "client" => { + if let Some(event_id) = event.get("event_id") { + assert_nonempty_string(Some(event_id), &format!("{context}.event_id")); + } + } + "server" => assert_nonempty_string(event.get("event_id"), &format!("{context}.event_id")), + other => panic!("{context} has unsupported direction {other}"), + } +} + +fn canonical_base64_decoded_len(value: &str, context: &str) -> usize { + assert!(value.is_ascii(), "{context} Base64 is ASCII"); + assert_eq!(value.len() % 4, 0, "{context} Base64 has complete quartets"); + let padding = value.bytes().rev().take_while(|byte| *byte == b'=').count(); + assert!(padding <= 2, "{context} Base64 has valid padding"); + let payload_len = value.len() - padding; + assert!( + value[..payload_len] + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/')), + "{context} Base64 has only alphabet characters" + ); + assert!( + value[payload_len..].bytes().all(|byte| byte == b'='), + "{context} Base64 padding is trailing" + ); + value.len() / 4 * 3 - padding +} + +fn assert_valid_commit_audio(name: &str, events: &[Value]) { + let mut buffered_audio_bytes = 0; + for (index, entry) in events.iter().enumerate() { + let direction = entry["direction"].as_str().expect("direction is a string"); + let message = object(&entry["message"], &format!("{name}[{index}].message")); + let event_type = message["type"].as_str().expect("event type is a string"); + if direction == "client" { + match event_type { + "input_audio_buffer.append" => { + let audio = message["audio"].as_str().expect("append audio is a string"); + buffered_audio_bytes += canonical_base64_decoded_len( + audio, + &format!("{name}[{index}].message.audio"), + ); + } + "input_audio_buffer.commit" => assert!( + buffered_audio_bytes >= MINIMUM_COMMIT_AUDIO_BYTES, + "{name}[{index}] commits {buffered_audio_bytes} PCM16 bytes, below 100 ms" + ), + _ => {} + } + } else if matches!( + event_type, + "input_audio_buffer.committed" | "input_audio_buffer.cleared" + ) { + buffered_audio_bytes = 0; + } + } +} + +fn assert_server_event_fields(value: &Value, context: &str) { + let event = object(value, context); + let event_type = event["type"] + .as_str() + .expect("server event type is a string"); + let fields = match event_type { + "session.created" | "session.updated" => &["event_id", "session", "type"][..], + "input_audio_buffer.committed" => &["event_id", "item_id", "previous_item_id", "type"][..], + "input_audio_buffer.cleared" => &["event_id", "type"][..], + "conversation.item.created" => &["event_id", "item", "previous_item_id", "type"][..], + "conversation.item.input_audio_transcription.delta" => { + &["content_index", "delta", "event_id", "item_id", "type"][..] + } + "conversation.item.input_audio_transcription.completed" => &[ + "content_index", + "event_id", + "item_id", + "transcript", + "type", + "usage", + ][..], + "conversation.item.input_audio_transcription.failed" => { + &["content_index", "error", "event_id", "item_id", "type"][..] + } + "conversation.item.input_audio_transcription.hypothesis" => &[ + "agreed", + "audio_end_ms", + "audio_start_ms", + "content_index", + "event_id", + "finalized", + "item_id", + "revision", + "tentative", + "transcript", + "type", + ][..], + "error" => &["error", "event_id", "type"][..], + other => panic!("{context} has unsupported server event type {other}"), + }; + assert_exact_keys(event, fields, context); + if matches!(event_type, "session.created" | "session.updated") { + assert_session(&event["session"], &format!("{context}.session")); + } + if let Some(content_index) = event.get("content_index") { + assert_eq!(content_index, 0, "{context}.content_index is zero"); + } + if let Some(previous) = event.get("previous_item_id") { + assert!( + previous.is_null() || previous.as_str().is_some_and(|id| !id.is_empty()), + "{context}.previous_item_id is null or opaque" + ); + } + if event_type == "conversation.item.created" { + let item = object(&event["item"], &format!("{context}.item")); + assert_exact_keys( + item, + &["content", "id", "role", "status", "type"], + &format!("{context}.item"), + ); + assert_nonempty_string(item.get("id"), &format!("{context}.item.id")); + assert_eq!(item["type"], "message"); + assert_eq!(item["status"], "completed"); + assert_eq!(item["role"], "user"); + let item_entries = item["content"] + .as_array() + .filter(|entries| entries.len() == 1) + .expect("created item has exactly one content entry"); + let audio_entry = object(&item_entries[0], &format!("{context}.item.content[0]")); + assert_exact_keys( + audio_entry, + &["transcript", "type"], + &format!("{context}.item.content[0]"), + ); + assert_eq!(audio_entry["type"], "input_audio"); + assert!(audio_entry["transcript"].is_null()); + } + if event_type == "conversation.item.input_audio_transcription.failed" { + let error = object(&event["error"], &format!("{context}.error")); + assert!( + ["code", "message", "type"] + .iter() + .all(|field| error.contains_key(*field)), + "{context}.error has every required field" + ); + assert!( + error + .keys() + .all(|field| matches!(field.as_str(), "code" | "message" | "param" | "type")) + ); + } +} + +#[test] +fn canonical_realtime_events_are_complete_strict_and_round_trip() { + let actual_files = std::fs::read_dir(fixture_dir()) + .expect("canonical fixture directory reads") + .map(|entry| { + entry + .expect("fixture directory entry reads") + .file_name() + .to_string_lossy() + .into_owned() + }) + .collect::>(); + let expected_files = FIXTURE_FILES + .iter() + .map(|name| (*name).to_owned()) + .collect::>(); + assert_eq!( + actual_files, expected_files, + "fixture file set is canonical" + ); + + let clients = fixture("client-events.json"); + assert_exact_cases(&clients, CLIENT_CASES, "client event cases"); + let clients = object(&clients, "client event cases"); + let client_types = [ + ("session_update", "session.update"), + ("input_audio_buffer_append", "input_audio_buffer.append"), + ("input_audio_buffer_commit", "input_audio_buffer.commit"), + ("input_audio_buffer_clear", "input_audio_buffer.clear"), + ]; + for (case, event_type) in client_types { + let event = object(&clients[case], case); + assert_eq!(event["type"], event_type, "{case} pins its type literal"); + assert_wire_event(&clients[case], "client", case); + } + assert_exact_keys( + object(&clients["session_update"], "session_update"), + &["event_id", "session", "type"], + "session_update", + ); + assert_exact_keys( + object(&clients["input_audio_buffer_append"], "append"), + &["audio", "event_id", "type"], + "append", + ); + assert_exact_keys( + object(&clients["input_audio_buffer_commit"], "commit"), + &["type"], + "commit", + ); + assert_exact_keys( + object(&clients["input_audio_buffer_clear"], "clear"), + &["type"], + "clear", + ); + let update = object( + &clients["session_update"]["session"], + "session_update.session", + ); + assert_exact_keys( + update, + &["audio", "include", "type"], + "session_update.session", + ); + assert_eq!(update["type"], "transcription"); + let update_audio = object(&update["audio"], "session_update.session.audio"); + assert_exact_keys(update_audio, &["input"], "session_update.session.audio"); + let update_input = object(&update_audio["input"], "session_update.session.audio.input"); + assert_exact_keys( + update_input, + &[ + "format", + "noise_reduction", + "transcription", + "turn_detection", + ], + "session_update.session.audio.input", + ); + + let sessions = fixture("effective-sessions.json"); + assert_exact_cases(&sessions, &["default", "updated"], "effective sessions"); + assert_session(&sessions["default"], "default session"); + assert_session(&sessions["updated"], "updated session"); + + let servers = fixture("server-events.json"); + assert_exact_cases(&servers, SERVER_CASES, "server event cases"); + let servers = object(&servers, "server event cases"); + for (case, event) in servers { + assert_wire_event(event, "server", case); + assert_server_event_fields(event, case); + } + assert_eq!(servers["session_created"]["session"], sessions["default"]); + assert_eq!(servers["session_updated"]["session"], sessions["updated"]); + assert_error( + &servers["error_correlated"], + ErrorCorrelation::Client("client_bad_update"), + "error_correlated", + ); + assert_error( + &servers["error_minimal"], + ErrorCorrelation::Omitted, + "error_minimal", + ); + assert_error( + &servers["error_uncorrelated"], + ErrorCorrelation::Null, + "error_uncorrelated", + ); + + let completed = object(&servers["transcription_completed"], "completed"); + let usage = object(&completed["usage"], "completed.usage"); + assert_exact_keys(usage, &["seconds", "type"], "completed.usage"); + assert_eq!(usage["type"], "duration"); + assert!( + usage["seconds"] + .as_f64() + .is_some_and(|seconds| seconds >= 0.0), + "duration usage is nonnegative" + ); + + let hypothesis = object(&servers["transcription_hypothesis"], "hypothesis"); + let joined = ["finalized", "agreed", "tentative"] + .map(|field| { + hypothesis[field] + .as_str() + .expect("hypothesis text is a string") + }) + .concat(); + assert_eq!(hypothesis["transcript"], joined); + assert!(hypothesis["revision"].as_u64().is_some()); + let start = hypothesis["audio_start_ms"] + .as_u64() + .expect("hypothesis start is unsigned"); + let end = hypothesis["audio_end_ms"] + .as_u64() + .expect("hypothesis end is unsigned"); + assert!(start <= end, "hypothesis span is half-open and ordered"); + + let mut server_ids = HashSet::new(); + for event in servers.values() { + let id = event["event_id"] + .as_str() + .expect("server event ID is a string"); + assert!(server_ids.insert(id), "server event IDs are independent"); + } + let session_ids = sessions + .as_object() + .expect("sessions object") + .values() + .map(|session| session["id"].as_str().expect("session ID is a string")) + .collect::>(); + let item_ids = servers + .values() + .filter_map(|event| event.get("item_id").and_then(Value::as_str)) + .chain(servers["conversation_item_created"]["item"]["id"].as_str()) + .collect::>(); + assert!(server_ids.is_disjoint(&session_ids)); + assert!(server_ids.is_disjoint(&item_ids)); + assert!(session_ids.is_disjoint(&item_ids)); +} + +#[test] +fn canonical_realtime_sequences_cover_valid_and_invalid_contract_paths() { + let valid = fixture("valid-sequences.json"); + assert_exact_cases(&valid, VALID_SEQUENCE_CASES, "valid sequence cases"); + for (name, sequence) in object(&valid, "valid sequence cases") { + let sequence = object(sequence, name); + assert_exact_keys(sequence, &["events", "invariants"], name); + let events = sequence["events"] + .as_array() + .expect("valid sequence events are an array"); + assert!(!events.is_empty(), "{name} has events"); + for (index, entry) in events.iter().enumerate() { + let entry = object(entry, &format!("{name}[{index}]")); + assert_exact_keys( + entry, + &["direction", "message"], + &format!("{name}[{index}]"), + ); + let direction = entry["direction"] + .as_str() + .expect("sequence direction is a string"); + assert_wire_event( + &entry["message"], + direction, + &format!("{name}[{index}].message"), + ); + if direction == "server" { + assert_server_event_fields(&entry["message"], &format!("{name}[{index}].message")); + } + } + assert_valid_commit_audio(name, events); + let invariants = sequence["invariants"] + .as_array() + .expect("valid sequence invariants are an array"); + assert!( + !invariants.is_empty() && invariants.iter().all(Value::is_string), + "{name} names the behavior it freezes" + ); + } + let revisions = valid["hypothesis_negotiation"]["events"] + .as_array() + .expect("hypothesis sequence events") + .iter() + .filter(|entry| { + entry["message"]["type"] == "conversation.item.input_audio_transcription.hypothesis" + }) + .map(|entry| { + entry["message"]["revision"] + .as_u64() + .expect("hypothesis revision is unsigned") + }) + .collect::>(); + assert_eq!(revisions, [1, 2], "hypothesis revisions increase"); + + let invalid = fixture("invalid-sequences.json"); + assert_exact_cases(&invalid, INVALID_SEQUENCE_CASES, "invalid sequence cases"); + for (name, sequence) in object(&invalid, "invalid sequence cases") { + let sequence = object(sequence, name); + assert_exact_keys( + sequence, + &[ + "effective_session_after", + "expected_error", + "input", + "keeps_connection_usable", + ], + name, + ); + assert!( + sequence["keeps_connection_usable"] + .as_bool() + .is_some_and(|usable| usable), + "{name} is a recoverable client or capacity error" + ); + assert!( + matches!( + sequence["effective_session_after"].as_str(), + Some("default" | "updated") + ), + "{name} names the unchanged effective session" + ); + let input = object(&sequence["input"], &format!("{name}.input")); + let correlation = match input.get("message") { + None => ErrorCorrelation::Omitted, + Some(message) => { + match object(message, &format!("{name}.input.message")).get("event_id") { + None => ErrorCorrelation::Omitted, + Some(Value::String(client_id)) => ErrorCorrelation::Client(client_id), + Some(_) => ErrorCorrelation::Null, + } + } + }; + assert_error( + &sequence["expected_error"], + correlation, + &format!("{name}.expected_error"), + ); + assert!( + input.contains_key("message") || input.contains_key("wire_text"), + "{name} supplies a wire message or malformed wire text" + ); + } +} diff --git a/crates/gateway-stt/tests/it/realtime_session.rs b/crates/gateway-stt/tests/it/realtime_session.rs new file mode 100644 index 00000000..07dcebc9 --- /dev/null +++ b/crates/gateway-stt/tests/it/realtime_session.rs @@ -0,0 +1,903 @@ +use std::future::{Future, pending}; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::task::{Context, Poll}; +use std::time::Duration; + +use base64::Engine as _; +use futures_util::FutureExt as _; +use gateway_stt::test_fixtures::{ + RealtimeSessionFixture, RealtimeSessionRegistryFixture, ScriptedDecoder, ScriptedModelFactory, +}; + +const SESSION_CAPACITY: usize = 8; +const CANCEL_JOIN_CAPACITY: usize = 8; +const COMMITTED_ITEM_CAPACITY: usize = 4; +const RESULT_CAPACITY: usize = 16; +static BLOCKING_TASK_TEST: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn encoded(samples: &[i16]) -> String { + let bytes = samples + .iter() + .flat_map(|sample| sample.to_le_bytes()) + .collect::>(); + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +fn closed_segment() -> String { + let mut samples = vec![16_384; 24_000]; + samples.extend(vec![0; 72_000]); + encoded(&samples) +} + +fn update(prompt: &str, include: bool) -> String { + serde_json::json!({ + "type": "session.update", + "session": { + "type": "transcription", + "audio": {"input": {"transcription": {"prompt": prompt}}}, + "include": if include { + vec!["item.input_audio_transcription.hypothesis"] + } else { + Vec::<&str>::new() + } + } + }) + .to_string() +} + +#[allow( + clippy::expect_used, + reason = "a fixture registry has no prior session that could consume capacity" +)] +fn session() -> RealtimeSessionFixture { + RealtimeSessionRegistryFixture::default() + .register() + .expect("session registers") +} + +struct BlockingPoll { + started: Arc<(Mutex, Condvar)>, + release: Arc, +} + +impl Future for BlockingPoll { + type Output = String; + + fn poll(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll { + let mut started = self + .started + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *started = true; + self.started.1.notify_all(); + drop(started); + while !self.release.load(Ordering::Acquire) { + std::thread::yield_now(); + } + Poll::Ready("released".to_owned()) + } +} + +struct BlockingFinalization(BlockingPoll); + +impl Future for BlockingFinalization { + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll { + Pin::new(&mut self.0).poll(context).map(Ok) + } +} + +fn wait_until_started(started: &Arc<(Mutex, Condvar)>) { + let state = started + .0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let (state, timeout) = started + .1 + .wait_timeout_while(state, Duration::from_secs(1), |started| !*started) + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert!(!timeout.timed_out() && *state, "blocked task starts"); +} + +async fn wait_until(predicate: impl Fn() -> bool) { + assert!( + tokio::time::timeout(Duration::from_secs(1), async { + loop { + if predicate() { + return; + } + tokio::task::yield_now().await; + } + }) + .await + .is_ok(), + "condition reaches its wall-clock deadline" + ); +} + +#[test] +fn session_registration_has_no_wait_queue_at_capacity() { + let registry = RealtimeSessionRegistryFixture::default(); + let sessions = (0..SESSION_CAPACITY) + .map(|_| registry.register().expect("session is admitted")) + .collect::>(); + + assert_eq!( + registry.register().expect_err("ninth session is rejected"), + "the realtime transcription session limit is reached" + ); + drop(sessions); + assert!( + registry.register().is_ok(), + "release immediately reopens admission" + ); +} + +#[test] +fn first_append_freezes_configuration_and_clear_resets_audio_state() { + let mut reused = session(); + reused + .update_text(&update("first", true)) + .expect("first update applies"); + reused + .append_base64(&base64::engine::general_purpose::STANDARD.encode([0x7f])) + .expect("odd byte appends"); + let first = reused.input_snapshot().expect("first snapshot exists"); + + reused + .update_text(&update("second", false)) + .expect("second update applies"); + assert_eq!( + reused.input_snapshot().expect("snapshot remains").prompt(), + "first" + ); + reused.clear().expect("input clears"); + reused + .append_base64(&encoded(&vec![123; 2_400])) + .expect("replacement input appends"); + let second = reused.input_snapshot().expect("second snapshot exists"); + assert_ne!(first.item_id(), second.item_id()); + assert_eq!(second.prompt(), "second"); + assert!(!second.include_hypothesis()); + + let mut fresh = session(); + fresh + .update_text(&update("second", false)) + .expect("fresh update applies"); + fresh + .append_base64(&encoded(&vec![123; 2_400])) + .expect("fresh input appends"); + assert_eq!(reused.resampled_audio(), fresh.resampled_audio()); +} + +#[test] +fn failed_first_append_does_not_capture_configuration() { + let mut session = session(); + session + .update_text(&update("before", false)) + .expect("first update applies"); + assert!(session.append_base64("not base64").is_err()); + assert!(session.input_snapshot().is_none()); + + session + .update_text(&update("after", true)) + .expect("replacement update applies"); + session + .append_base64(&encoded(&[0, 1])) + .expect("valid append succeeds"); + let snapshot = session.input_snapshot().expect("snapshot exists"); + assert_eq!(snapshot.prompt(), "after"); + assert!(snapshot.include_hypothesis()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[allow( + clippy::await_holding_lock, + reason = "the process-wide test lock serializes deliberately blocked runtime workers" +)] +async fn dropping_session_retains_admission_until_interim_cleanup_joins() { + let _serial = BLOCKING_TASK_TEST + .lock() + .expect("test lock is not poisoned"); + let registry = RealtimeSessionRegistryFixture::default(); + let mut session = registry.register().expect("session registers"); + let other_sessions = (1..SESSION_CAPACITY) + .map(|_| registry.register().expect("capacity is admitted")) + .collect::>(); + session + .append_base64(&encoded(&[0, 0])) + .expect("input appends"); + let started = Arc::new((Mutex::new(false), Condvar::new())); + let release = Arc::new(AtomicBool::new(false)); + session + .spawn_interim(BlockingPoll { + started: Arc::clone(&started), + release: Arc::clone(&release), + }) + .expect("interim starts"); + + wait_until_started(&started); + let cleanup_events = registry.cleanup_event_count(); + drop(session); + let cleanup = registry.cleanup_notified(); + tokio::pin!(cleanup); + assert!( + cleanup.as_mut().now_or_never().is_none(), + "cleanup waiter starts before task release" + ); + assert_eq!( + registry.active(), + SESSION_CAPACITY, + "retiring work keeps admission owned" + ); + assert_eq!( + registry.register().expect_err("capacity remains occupied"), + "the realtime transcription session limit is reached" + ); + + release.store(true, Ordering::Release); + tokio::time::timeout(Duration::from_secs(1), cleanup) + .await + .expect("registry cleanup reaches its wall-clock deadline"); + assert_eq!( + registry.cleanup_event_count(), + cleanup_events + 1, + "one retirement emits exactly one cleanup event" + ); + assert_eq!(registry.active(), SESSION_CAPACITY - 1); + let replacement = registry + .register() + .expect("completed cleanup immediately reopens admission"); + drop(replacement); + drop(other_sessions); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[allow( + clippy::await_holding_lock, + reason = "the process-wide test lock serializes deliberately blocked runtime workers" +)] +async fn dropping_session_retains_admission_until_finalization_cleanup_joins() { + let _serial = BLOCKING_TASK_TEST + .lock() + .expect("test lock is not poisoned"); + let registry = RealtimeSessionRegistryFixture::default(); + let mut session = registry.register().expect("session registers"); + let other_sessions = (1..SESSION_CAPACITY) + .map(|_| registry.register().expect("capacity is admitted")) + .collect::>(); + let item = { + append_committable(&mut session); + session.commit().expect("item commits") + }; + let started = Arc::new((Mutex::new(false), Condvar::new())); + let release = Arc::new(AtomicBool::new(false)); + session + .replace_finalization( + item.item_id(), + BlockingFinalization(BlockingPoll { + started: Arc::clone(&started), + release: Arc::clone(&release), + }), + ) + .expect("controlled finalization starts"); + + wait_until_started(&started); + let cleanup_events = registry.cleanup_event_count(); + let cleanup = registry.cleanup_notified(); + tokio::pin!(cleanup); + assert!( + cleanup.as_mut().now_or_never().is_none(), + "cleanup waiter starts before session retirement" + ); + drop(session); + assert!( + tokio::time::timeout(Duration::from_millis(25), cleanup.as_mut()) + .await + .is_err(), + "parked finalization keeps cleanup pending" + ); + assert_eq!( + registry.active(), + SESSION_CAPACITY, + "retiring finalization keeps admission owned" + ); + assert_eq!( + registry.register().expect_err("capacity remains occupied"), + "the realtime transcription session limit is reached" + ); + + release.store(true, Ordering::Release); + tokio::time::timeout(Duration::from_secs(1), cleanup.as_mut()) + .await + .expect("finalization cleanup reaches its wall-clock deadline"); + assert_eq!( + registry.cleanup_event_count(), + cleanup_events + 1, + "one finalization retirement emits exactly one cleanup event" + ); + assert_eq!(registry.active(), SESSION_CAPACITY - 1); + let replacement = registry + .register() + .expect("completed finalization cleanup immediately reopens admission"); + drop(replacement); + drop(other_sessions); +} + +#[tokio::test] +async fn retired_task_join_failures_are_preserved() { + let registry = RealtimeSessionRegistryFixture::default(); + let mut session = registry.register().expect("session registers"); + session + .append_base64(&encoded(&[0, 0])) + .expect("input appends"); + let (started, started_rx) = tokio::sync::oneshot::channel(); + session + .spawn_interim(async move { + let _ = started.send(()); + panic!("retired interim task panic") + }) + .expect("interim starts"); + tokio::time::timeout(Duration::from_secs(1), started_rx) + .await + .expect("panicking task starts before retirement") + .expect("panicking task reports startup"); + let cleanup = registry.cleanup_notified(); + tokio::pin!(cleanup); + assert!( + cleanup.as_mut().now_or_never().is_none(), + "cleanup waiter starts before retirement" + ); + + drop(session); + tokio::time::timeout(Duration::from_secs(1), cleanup) + .await + .expect("failed task cleanup reaches its wall-clock deadline"); + assert_eq!( + registry.retired_task_failures(), + 1, + "retired task panic remains observable after admission release" + ); +} + +#[tokio::test] +async fn missing_cleanup_notification_reaches_wall_clock_deadline() { + let registry = RealtimeSessionRegistryFixture::default(); + + assert!( + tokio::time::timeout(Duration::from_millis(25), registry.cleanup_notified()) + .await + .is_err(), + "missing cleanup reaches the bounded wall-clock timeout" + ); +} + +#[tokio::test] +async fn canceling_finish_keeps_current_task_owned_for_retry() { + let mut session = session(); + session + .append_base64(&encoded(&[0, 0])) + .expect("input appends"); + let (send, receive) = tokio::sync::oneshot::channel(); + session + .spawn_interim(async move { receive.await.expect("completion is sent") }) + .expect("interim starts"); + + assert!( + session.finish_interim().now_or_never().is_none(), + "first poll remains pending" + ); + send.send("accepted".to_owned()) + .expect("receiver remains owned"); + let event = session + .finish_interim() + .await + .expect("retry joins") + .expect("current result is accepted"); + assert_eq!(event["delta"], "accepted"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[allow( + clippy::await_holding_lock, + reason = "the process-wide test lock serializes deliberately blocked runtime workers" +)] +async fn canceling_join_keeps_capacity_owned_until_retry_completes() { + let _serial = BLOCKING_TASK_TEST + .lock() + .expect("test lock is not poisoned"); + let mut session = session(); + session + .append_base64(&encoded(&[0, 0])) + .expect("input appends"); + let started = Arc::new((Mutex::new(false), Condvar::new())); + let release = Arc::new(AtomicBool::new(false)); + session + .spawn_interim(BlockingPoll { + started: Arc::clone(&started), + release: Arc::clone(&release), + }) + .expect("interim starts"); + wait_until_started(&started); + session.clear().expect("interim retires"); + + assert!( + session.join_canceled().now_or_never().is_none(), + "first join poll remains pending" + ); + assert_eq!(session.canceled_join_count(), 1); + release.store(true, Ordering::Release); + session.join_canceled().await.expect("retry joins task"); + assert_eq!(session.canceled_join_count(), 0); +} + +#[tokio::test] +async fn canceled_join_capacity_is_exact_and_recoverable() { + let mut session = session(); + for _ in 0..CANCEL_JOIN_CAPACITY { + session + .append_base64(&encoded(&[0, 0])) + .expect("input appends"); + session + .spawn_interim(pending()) + .expect("interim starts within capacity"); + session.clear().expect("task is retained"); + } + assert_eq!(session.canceled_join_count(), CANCEL_JOIN_CAPACITY); + + session + .append_base64(&encoded(&[0, 0])) + .expect("capacity-plus-one input appends"); + session + .spawn_interim(pending()) + .expect("current task starts"); + assert_eq!( + session.clear().expect_err("next retirement is rejected"), + "the canceled interim task join capacity is reached" + ); + assert!(session.input_snapshot().is_some()); + session.join_canceled().await.expect("retired tasks join"); + session + .clear() + .expect("clear retries after capacity drains"); +} + +#[test] +fn stale_interim_is_rejected_before_event_id_allocation() { + let mut session = session(); + session + .append_base64(&encoded(&[0, 0])) + .expect("input appends"); + let (current_event, stale_event) = session + .accept_interim_across_clear("current", "stale") + .expect("interim clear scenario succeeds"); + let current_event = current_event.expect("current epoch is accepted"); + assert_eq!(current_event["delta"], "current"); + assert!(stale_event.is_none()); + assert_eq!( + session.allocated_event_count(), + 1, + "stale completion consumes no event ID" + ); +} + +#[allow( + clippy::expect_used, + reason = "the helper establishes valid canonical fixture audio and input" +)] +fn append_committable(session: &mut RealtimeSessionFixture) -> String { + session + .append_base64(&encoded(&vec![0; 2_400])) + .expect("committable input appends"); + session + .input_snapshot() + .expect("provisional input exists") + .item_id() + .to_owned() +} + +#[allow( + clippy::expect_used, + reason = "the helper establishes valid decodable fixture audio" +)] +fn append_decodable(session: &mut RealtimeSessionFixture) { + session + .append_base64(&encoded(&vec![512; 12_000])) + .expect("decodable input appends"); +} + +#[test] +fn commit_promotes_the_provisional_id_and_preserves_durable_lineage() { + let mut session = session(); + let first_provisional = append_committable(&mut session); + let first = session.commit().expect("first item commits"); + assert_eq!(first.item_id(), first_provisional); + assert_eq!(first.previous_item_id(), None); + + session + .finalize_completed(first.item_id(), "first") + .expect("first item finalizes"); + let second_provisional = append_committable(&mut session); + let second = session.commit().expect("second item commits"); + assert_eq!(second.item_id(), second_provisional); + assert_eq!(second.previous_item_id(), Some(first.item_id())); +} + +#[test] +fn committed_capacity_is_reserved_before_input_detach_and_retryable() { + let mut session = session(); + let mut committed = Vec::new(); + for _ in 0..COMMITTED_ITEM_CAPACITY { + append_committable(&mut session); + committed.push(session.commit().expect("item commits within capacity")); + } + assert_eq!(session.committed_count(), COMMITTED_ITEM_CAPACITY); + + let retry_id = append_committable(&mut session); + assert_eq!( + session.commit().expect_err("fifth item is rejected"), + "the committed realtime item limit is reached" + ); + assert_eq!( + session + .input_snapshot() + .expect("rejected commit preserves input") + .item_id(), + retry_id + ); + + session + .finalize_completed(committed[0].item_id(), "done") + .expect("one item releases capacity"); + session.drain_results(); + let retried = session.commit().expect("same input retries"); + assert_eq!(retried.item_id(), retry_id); +} + +#[tokio::test] +async fn four_items_finalize_in_reverse_order_without_crossing_ownership() { + let interim = ScriptedDecoder::new(); + let final_decoder = ScriptedDecoder::new(); + for index in 0..COMMITTED_ITEM_CAPACITY { + final_decoder.push_text(format!("result-{index}")); + } + let registry = RealtimeSessionRegistryFixture::default(); + let mut session = registry + .register_with_scripted_engine( + ScriptedModelFactory::new(interim).with_final(final_decoder.clone()), + ) + .expect("scripted session starts"); + let mut ids = Vec::new(); + final_decoder + .with_next_decode_blocked( + Duration::from_secs(1), + || async { + for index in 0..COMMITTED_ITEM_CAPACITY { + session + .update_text(&update(&format!("prompt-{index}"), true)) + .expect("item prompt updates"); + append_decodable(&mut session); + ids.push(session.commit().expect("item commits").item_id().to_owned()); + } + &session + }, + |session| async { + assert_eq!( + session.finalizing_count(), + COMMITTED_ITEM_CAPACITY, + "every committed take owns an asynchronous finalization" + ); + }, + ) + .await + .expect("one accurate final decode blocks while all item tasks remain owned"); + + for (index, item_id) in ids.iter().enumerate().rev() { + assert_eq!( + session + .committed_prompt_and_guidance(item_id) + .expect("item retains its immutable take state"), + (format!("prompt-{index}"), vec![format!("prompt-{index}")]) + ); + session + .finish_finalization(item_id) + .await + .expect("item finalizes independently through its take"); + } + assert_eq!(session.finalizing_count(), 0); + let terminals = session.drain_results(); + assert_eq!( + terminals + .iter() + .filter_map(|event| event["item_id"].as_str()) + .collect::>(), + ids.iter().rev().map(String::as_str).collect::>() + ); + let requests = final_decoder.requests(); + assert_eq!(requests.len(), COMMITTED_ITEM_CAPACITY); + for (index, event) in terminals.iter().enumerate() { + let item_index = COMMITTED_ITEM_CAPACITY - index - 1; + let prompt = format!("prompt-{item_index}"); + let request_index = requests + .iter() + .position(|request| request.guidance() == [prompt.as_str()]) + .expect("each item guidance reaches one final request"); + assert_eq!(event["transcript"], format!("result-{request_index}")); + } +} + +#[tokio::test] +async fn canceling_item_finish_keeps_finalization_owned_for_retry() { + let interim = ScriptedDecoder::new(); + let final_decoder = ScriptedDecoder::new(); + final_decoder.push_text("authoritative"); + let registry = RealtimeSessionRegistryFixture::default(); + let mut session = registry + .register_with_scripted_engine( + ScriptedModelFactory::new(interim).with_final(final_decoder.clone()), + ) + .expect("scripted session starts"); + let item_id = final_decoder + .with_next_decode_blocked( + Duration::from_secs(1), + || async { + append_decodable(&mut session); + let item_id = session.commit().expect("item commits").item_id().to_owned(); + (&mut session, item_id) + }, + |(session, item_id)| async { + assert!( + session + .finish_finalization(&item_id) + .now_or_never() + .is_none(), + "canceling the first join poll cannot detach finalization" + ); + assert_eq!(session.finalizing_count(), 1); + item_id + }, + ) + .await + .expect("the accurate decode reaches the blocked scenario"); + session + .finish_finalization(&item_id) + .await + .expect("retry joins the same finalization"); + + let results = session.drain_results(); + assert_eq!(results.len(), 1); + assert_eq!(results[0]["type"], "completed"); + assert_eq!(results[0]["transcript"], "authoritative"); +} + +#[test] +fn result_capacity_hypothesis_replacement_and_terminal_reservation_are_independent() { + let mut session = session(); + append_committable(&mut session); + let item = session.commit().expect("item commits"); + for index in 0..RESULT_CAPACITY { + session + .push_delta(item.item_id(), &format!("delta-{index}")) + .expect("result enters bounded capacity"); + } + assert_eq!( + session + .push_delta(item.item_id(), "overflow") + .expect_err("capacity-plus-one is rejected"), + "the realtime session result capacity is reached" + ); + + session + .replace_hypothesis(item.item_id(), 1, "old") + .expect("first hypothesis enters its slot"); + session + .replace_hypothesis(item.item_id(), 2, "new") + .expect("new hypothesis replaces old"); + session + .finalize_completed(item.item_id(), "authoritative") + .expect("terminal uses its reserved slot despite saturation"); + + let results = session.drain_results(); + assert_eq!( + results + .iter() + .filter(|event| event["type"] == "delta") + .count(), + RESULT_CAPACITY + ); + let hypothesis = results + .iter() + .find(|event| event["type"] == "hypothesis") + .expect("one replaceable hypothesis remains"); + assert_eq!(hypothesis["revision"], 2); + assert_eq!(hypothesis["transcript"], "new"); + assert_eq!( + results + .iter() + .filter(|event| event["type"] == "completed") + .count(), + 1 + ); +} + +#[test] +fn pending_precommit_failure_blocks_append_but_commits_one_item_failure() { + let mut session = session(); + let item_id = append_committable(&mut session); + session + .fail_precommit("accurate segment failed") + .expect("failure is retained by the input"); + assert_eq!( + session + .append_base64(&encoded(&[0, 0])) + .expect_err("failed input rejects later audio"), + "accurate segment failed" + ); + + let committed = session + .commit() + .expect("failed input still establishes item"); + assert_eq!(committed.item_id(), item_id); + assert_eq!( + session + .finalize_failed(committed.item_id(), "duplicate") + .expect_err("a second terminal is rejected"), + "the committed item already reached a terminal outcome" + ); + let results = session.drain_results(); + assert_eq!(results.len(), 1); + assert_eq!(results[0]["type"], "failed"); + assert_eq!(results[0]["message"], "accurate segment failed"); +} + +#[test] +fn clear_discards_pending_precommit_failure_without_creating_an_item() { + let mut session = session(); + append_committable(&mut session); + session + .fail_precommit("discard me") + .expect("failure is retained"); + session.clear().expect("failed uncommitted input clears"); + assert_eq!(session.committed_count(), 0); + assert!(session.drain_results().is_empty()); +} + +#[tokio::test] +async fn asynchronous_final_failure_is_observed_before_the_next_append_and_at_commit() { + let interim = ScriptedDecoder::new(); + let final_decoder = ScriptedDecoder::new(); + final_decoder.push_error("late accurate failure"); + let registry = RealtimeSessionRegistryFixture::default(); + let mut session = registry + .register_with_scripted_engine( + ScriptedModelFactory::new(interim).with_final(final_decoder.clone()), + ) + .expect("scripted session starts"); + + final_decoder + .with_next_decode_blocked( + Duration::from_secs(1), + || async { + session + .append_base64(&closed_segment()) + .expect("closed segment enters the production take"); + }, + |()| async {}, + ) + .await + .expect("the accurate segment blocks between appends"); + wait_until(|| session.pending_failure().is_some()).await; + let failure = session + .pending_failure() + .expect("the take owns the asynchronous failure"); + + assert_eq!( + session + .append_base64(&encoded(&[1, 2])) + .expect_err("the next append is rejected before mutating audio"), + failure + ); + let item = session + .commit() + .expect("commit still establishes the failed item"); + assert_eq!(session.finalizing_count(), 0); + let results = session.drain_results(); + assert_eq!(results.len(), 1); + assert_eq!(results[0]["item_id"], item.item_id()); + assert_eq!(results[0]["type"], "failed"); + assert_eq!(results[0]["message"], failure); +} + +#[tokio::test] +async fn production_final_segment_admission_is_exact_and_fails_atomically() { + let interim = ScriptedDecoder::new(); + let final_decoder = ScriptedDecoder::new(); + final_decoder.push_text("first"); + let registry = RealtimeSessionRegistryFixture::default(); + let mut session = registry + .register_with_scripted_engine( + ScriptedModelFactory::new(interim).with_final(final_decoder.clone()), + ) + .expect("scripted session starts"); + + final_decoder + .with_next_decode_blocked( + Duration::from_secs(1), + || async { + session + .append_base64(&closed_segment()) + .expect("first closed segment is admitted"); + &mut session + }, + |session| async { + assert_eq!(session.pending_final_segments(), Some(1)); + + for expected in 2..=4 { + session + .append_base64(&closed_segment()) + .expect("segment is accepted through exact capacity"); + assert_eq!(session.pending_final_segments(), Some(expected)); + assert!(session.pending_failure().is_none()); + } + + session + .append_base64(&closed_segment()) + .expect("audio ingestion remains recoverable at segment saturation"); + assert_eq!(session.pending_final_segments(), Some(4)); + assert_eq!( + session.pending_failure().as_deref(), + Some("final segment capacity is reached") + ); + let item = session + .commit() + .expect("capacity failure atomically becomes an item failure"); + let results = session.drain_results(); + assert_eq!(results.len(), 1); + assert_eq!(results[0]["item_id"], item.item_id()); + assert_eq!(results[0]["message"], "final segment capacity is reached"); + }, + ) + .await + .expect("the first production segment blocks in final decoding"); +} + +#[tokio::test] +async fn commit_reserves_interim_join_capacity_before_detaching_input() { + let mut session = session(); + for _ in 0..CANCEL_JOIN_CAPACITY { + session + .append_base64(&encoded(&[0, 0])) + .expect("input appends"); + session + .spawn_interim(pending()) + .expect("interim starts within capacity"); + session.clear().expect("task is retained"); + } + + let provisional = append_committable(&mut session); + session + .spawn_interim(pending()) + .expect("current interim starts"); + assert_eq!( + session + .commit() + .expect_err("commit cannot detach an unowned task"), + "the canceled interim task join capacity is reached" + ); + assert_eq!( + session + .input_snapshot() + .expect("rejected commit preserves input") + .item_id(), + provisional + ); + session.join_canceled().await.expect("retired tasks join"); + assert_eq!( + session.commit().expect("retry commits").item_id(), + provisional + ); +} diff --git a/crates/gateway-stt/tests/it/service.rs b/crates/gateway-stt/tests/it/service.rs new file mode 100644 index 00000000..72a9107d --- /dev/null +++ b/crates/gateway-stt/tests/it/service.rs @@ -0,0 +1,106 @@ +//! Public speech-facade integration tests. + +use gateway_config::{Config, ProfileName}; +use gateway_stt::SpeechService; +use gateway_stt::test_fixtures::{ScriptedDecoder, ScriptedModelFactory, scripted_service}; + +use crate::common::fixture_service; + +#[test] +fn clones_observe_one_complete_scripted_generation() { + let factory = ScriptedModelFactory::new(ScriptedDecoder::new()) + .with_final(ScriptedDecoder::new()) + .with_gpu_available(true); + let service = scripted_service(factory, 15, 500).expect("scripted service starts"); + let clone = service.clone(); + + let status = clone.status(); + assert!(status.configured()); + assert!(status.ready()); + assert!(status.gpu()); + assert_eq!(status.generation(), Some(1)); + assert_eq!( + clone + .models() + .iter() + .map(gateway_stt::SpeechModelInfo::name) + .collect::>(), + ["scripted-interim", "scripted-final", "realtime-transcribe"] + ); + + service.shutdown(); + assert!(!clone.status().ready()); + assert!(clone.models().is_empty()); +} + +#[test] +fn logical_realtime_model_requires_both_physical_roles() { + let service = scripted_service(ScriptedModelFactory::new(ScriptedDecoder::new()), 15, 500) + .expect("single-model scripted service starts"); + + assert_eq!( + service + .models() + .iter() + .map(gateway_stt::SpeechModelInfo::name) + .collect::>(), + ["scripted-interim"] + ); + + service.shutdown(); +} + +#[expect( + clippy::expect_used, + reason = "fixture construction fails with the named catalog invariant" +)] +fn selected_speech_config(models: &str, selected: &str) -> Config { + Config::from_toml_str(&format!( + "config-version = 2\n\ + [server]\nbind = \"127.0.0.1:0\"\napi_key = \"k\"\n\ + {models}\ + [[profile]]\nname = \"speech\"\nmodels = {selected}\n" + )) + .expect("speech catalog parses") + .select_profile(&ProfileName::parse("speech").expect("profile name")) + .expect("speech profile selects") +} + +#[test] +fn physical_interim_cannot_claim_the_logical_realtime_identity() { + let config = selected_speech_config( + "[[stt_model]]\nname = \"realtime-transcribe\"\nrole = \"interim\"\n\ + source = \"/missing-interim.bin\"\nvram_gb = 1.0\n", + "[\"realtime-transcribe\"]", + ); + let error = SpeechService::new() + .prepare(&config, None) + .expect_err("the logical name is reserved before artifact access"); + assert!(error.to_string().contains("reserved"), "{error}"); +} + +#[test] +fn physical_final_in_a_pair_cannot_claim_the_logical_realtime_identity() { + let config = selected_speech_config( + "[[stt_model]]\nname = \"physical-interim\"\nrole = \"interim\"\n\ + source = \"/missing-interim.bin\"\nvram_gb = 1.0\n\ + [[stt_model]]\nname = \"realtime-transcribe\"\nrole = \"final\"\n\ + source = \"/missing-final.bin\"\nvram_gb = 1.0\n", + "[\"physical-interim\", \"realtime-transcribe\"]", + ); + let error = SpeechService::new() + .prepare(&config, None) + .expect_err("the logical name is reserved before artifact access"); + assert!(error.to_string().contains("reserved"), "{error}"); +} + +#[test] +#[ignore = "requires whisper test fixtures (tests/fixtures/)"] +fn switch_in_loads_and_switch_out_fully_unloads_the_generation() { + let service = fixture_service(false); + assert!(service.status().ready()); + assert_eq!(service.models()[0].name(), "speech"); + service.shutdown(); + assert!(!service.status().ready()); + assert!(service.models().is_empty()); +} diff --git a/crates/gateway-stt/tests/it/stt.rs b/crates/gateway-stt/tests/it/stt.rs deleted file mode 100644 index 23194d9e..00000000 --- a/crates/gateway-stt/tests/it/stt.rs +++ /dev/null @@ -1,421 +0,0 @@ -//! Characterization tests for the mechanically moved `/stt` socket. - -#![expect( - clippy::expect_used, - reason = "fixture construction fails the ignored live test with the invariant named" -)] - -use std::time::Duration; - -use futures_util::{SinkExt as _, StreamExt as _}; -use gateway_stt::{SttRuntime, SttState}; -use gateway_transcribe::fixtures::{jfk_samples, require_model}; -use serde_json::json; -use tokio_tungstenite::tungstenite; -use tokio_tungstenite::tungstenite::client::IntoClientRequest; - -use crate::common::{JsonSocket, TestServer}; - -async fn send_pcm(socket: &mut JsonSocket, frames: usize) { - socket.send_binary(vec![0u8; frames * 4]).await; -} - -async fn send_samples(socket: &mut JsonSocket, samples: &[f32]) { - const BLOCK: usize = 4096; - for chunk in samples.chunks(BLOCK) { - let mut bytes = Vec::with_capacity(chunk.len() * 4); - for sample in chunk { - bytes.extend_from_slice(&sample.to_le_bytes()); - } - socket.send_binary(bytes).await; - } -} - -fn fixture_server(with_final: bool) -> TestServer { - let cache = tempfile::tempdir().expect("cache tempdir"); - let source = require_model().display().to_string().replace('\\', "/"); - let cache_path = cache.path().display().to_string().replace('\\', "/"); - let final_model = if with_final { - format!( - "[[stt_model]]\nname = \"speech-final\"\nrole = \"final\"\nsource = {source:?}\nvram_gb = 1.0\n" - ) - } else { - String::new() - }; - let profile_models = if with_final { - "[\"speech\", \"speech-final\"]" - } else { - "[\"speech\"]" - }; - let catalog = gateway_config::Config::from_toml_str(&format!( - "config-version = 2\n\ - [server]\nbind = \"127.0.0.1:0\"\napi_key = \"k\"\n\ - [local]\ncache_dir = {cache_path:?}\n\ - [workshop.stt]\nwindow_seconds = 8\ninterval_ms = 400\n\ - [[stt_model]]\nname = \"speech\"\nrole = \"interim\"\nsource = {source:?}\nvram_gb = 1.0\n\ - {final_model}[[profile]]\nname = \"work\"\nmodels = {profile_models}\n" - )) - .expect("fixture catalog parses"); - let config = catalog - .select_profile(&gateway_config::ProfileName::parse("work").expect("profile name")) - .expect("fixture profile selects"); - let state = SttState::default(); - let runtime = SttRuntime::start(&config, state.clone(), None).expect("fixture engine loads"); - TestServer::spawn_with(state, Some(runtime)) -} - -#[tokio::test] -async fn a_take_counts_pcm_frames_and_tags_the_final_with_its_generation() { - let server = TestServer::spawn(); - let mut socket = JsonSocket::connect(&server.ws_url("/stt")).await; - socket.send_text("start").await; - assert_eq!( - socket.recv_json().await, - json!({"type": "stream", "generation": 1}), - "a start is answered by the stream announcement before any other frame" - ); - send_pcm(&mut socket, 128).await; - send_pcm(&mut socket, 64).await; - socket.send_binary(vec![0u8; 3]).await; - socket.send_text("stop").await; - - assert_eq!( - socket.recv_json().await, - json!({"type": "final", "text": "", "frames": 192, "generation": 1}), - "frames are counted, the partial sample is dropped, and no engine means an empty transcript" - ); - socket.close().await; -} - -#[tokio::test] -async fn the_workshop_relay_can_request_private_status_frames() { - let server = TestServer::spawn(); - let mut request = server - .ws_url("/stt") - .into_client_request() - .expect("request builds"); - request.headers_mut().insert( - "x-promptforge-workshop-status", - "1".parse().expect("status header parses"), - ); - let (mut socket, _response) = tokio_tungstenite::connect_async(request) - .await - .expect("socket connects"); - socket - .send(tungstenite::Message::Text("start".into())) - .await - .expect("start sends"); - let stream = socket - .next() - .await - .expect("stream frame arrives") - .expect("stream frame is valid") - .into_text() - .expect("stream frame is text"); - assert_eq!( - serde_json::from_str::(&stream).expect("stream frame is JSON"), - json!({"type": "stream", "generation": 1}) - ); - let status = socket - .next() - .await - .expect("status frame arrives") - .expect("status frame is valid") - .into_text() - .expect("status frame is text"); - assert_eq!( - serde_json::from_str::(&status).expect("status frame is JSON"), - json!({ - "type": "workshop_status", - "label": "Listening...", - "description": "a push-to-talk take is recording", - "severity": "info" - }) - ); - socket.close(None).await.expect("socket closes"); -} - -#[tokio::test] -async fn a_restart_increments_the_generation_and_a_new_connection_resets_it() { - let server = TestServer::spawn(); - let mut socket = JsonSocket::connect(&server.ws_url("/stt")).await; - socket.send_text("start").await; - assert_eq!( - socket.recv_json().await["generation"], - 1, - "the connection's first take is generation 1" - ); - send_pcm(&mut socket, 100).await; - socket.send_text("start").await; - assert_eq!( - socket.recv_json().await, - json!({"type": "stream", "generation": 2}), - "a restart announces the incremented generation" - ); - send_pcm(&mut socket, 10).await; - socket.send_text("stop").await; - let reply = socket.recv_json().await; - assert_eq!( - reply["generation"], 2, - "the final frame carries its take's generation" - ); - assert_eq!( - reply["frames"], 10, - "the second take counts only its own frames" - ); - - let mut second = JsonSocket::connect(&server.ws_url("/stt")).await; - second.send_text("start").await; - assert_eq!( - second.recv_json().await["generation"], - 1, - "generations are per-connection" - ); - socket.close().await; - second.close().await; -} - -#[tokio::test] -async fn stt_upgrade_keeps_the_loopback_origin_allowlist() { - let server = TestServer::spawn(); - let url = server.ws_url("/stt"); - let mut request = url.into_client_request().expect("request builds"); - request.headers_mut().insert( - "origin", - "https://evil.example" - .parse() - .expect("origin header parses"), - ); - let error = tokio_tungstenite::connect_async(request) - .await - .expect_err("foreign origin is refused"); - match error { - tungstenite::Error::Http(response) => { - assert_eq!(response.status(), tungstenite::http::StatusCode::FORBIDDEN); - } - other => panic!("expected HTTP refusal, got {other:?}"), - } -} - -#[tokio::test] -async fn unknown_text_is_ignored_without_changing_the_take() { - let server = TestServer::spawn(); - let mut socket = JsonSocket::connect(&server.ws_url("/stt")).await; - socket.send_text("start").await; - assert_eq!( - socket.recv_json().await, - json!({"type": "stream", "generation": 1}) - ); - socket.send_text("bogus").await; - send_pcm(&mut socket, 10).await; - socket.send_text("stop").await; - assert_eq!( - socket.recv_json().await, - json!({"type": "final", "text": "", "frames": 10, "generation": 1}) - ); - socket.close().await; -} - -#[tokio::test] -#[ignore = "requires whisper test fixtures (tests/fixtures/)"] -async fn speech_produces_generation_tagged_interim_and_final_frames() { - let server = fixture_server(false); - let mut socket = JsonSocket::connect(&server.ws_url("/stt")).await; - socket.send_text("start").await; - assert_eq!( - socket.recv_json().await, - json!({"type": "stream", "generation": 1}) - ); - send_samples(&mut socket, &jfk_samples()).await; - - let interim = socket - .recv_until(Duration::from_secs(90), |frame| { - frame["type"] == "interim" - && frame["tentative"] - .as_str() - .is_some_and(|text| text.to_lowercase().contains("country")) - }) - .await; - assert_eq!( - interim["generation"], 1, - "every interim frame is tagged with its take's generation" - ); - assert!( - interim["committed"].is_string(), - "every interim frame carries a committed string" - ); - - socket.send_text("stop").await; - let reply = socket - .recv_until(Duration::from_secs(180), |frame| frame["type"] == "final") - .await; - assert_eq!( - reply["generation"], 1, - "the final frame carries its take's generation" - ); - let text = reply["text"].as_str().expect("final text is a string"); - assert!( - text.to_lowercase().contains("country"), - "the final transcript names the fixture's words: {text:?}" - ); - socket.close().await; -} - -#[tokio::test] -#[ignore = "requires whisper test fixtures (tests/fixtures/)"] -async fn interim_only_stop_keeps_speech_before_a_silence_gap() { - let server = fixture_server(false); - let mut socket = JsonSocket::connect(&server.ws_url("/stt")).await; - socket.send_text("start").await; - assert_eq!(socket.recv_json().await["type"], "stream"); - send_samples(&mut socket, &jfk_samples()).await; - send_pcm(&mut socket, 3 * 16_000).await; - socket.send_text("stop").await; - let reply = socket - .recv_until(Duration::from_secs(180), |frame| frame["type"] == "final") - .await; - let text = reply["text"].as_str().expect("final text is a string"); - assert!( - text.to_lowercase().contains("country"), - "the fallback decodes the whole take, nothing consumed early: {text:?}" - ); - socket.close().await; -} - -#[tokio::test] -#[ignore = "requires whisper test fixtures (tests/fixtures/)"] -async fn silence_produces_no_interims_and_an_empty_final() { - let server = fixture_server(false); - let mut socket = JsonSocket::connect(&server.ws_url("/stt")).await; - socket.send_text("start").await; - assert_eq!(socket.recv_json().await["type"], "stream"); - send_pcm(&mut socket, 3 * 16_000).await; - socket.send_text("stop").await; - let reply = socket.recv_json().await; - assert_eq!( - reply, - json!({"type": "final", "text": "", "frames": 48_000, "generation": 1}), - "the first message after silence is the stop reply, not an interim" - ); - socket.close().await; -} - -async fn wait_for_committed(socket: &mut JsonSocket) -> String { - socket - .recv_until(Duration::from_secs(120), |frame| { - frame["type"] == "interim" - && frame["committed"] - .as_str() - .is_some_and(|text| text.to_lowercase().contains("country")) - }) - .await["committed"] - .as_str() - .expect("every interim frame carries a committed string") - .to_owned() -} - -#[tokio::test] -#[ignore = "requires whisper test fixtures (tests/fixtures/)"] -async fn final_frame_is_the_committed_prefix_plus_the_tail() { - let server = fixture_server(true); - let mut socket = JsonSocket::connect(&server.ws_url("/stt")).await; - socket.send_text("start").await; - assert_eq!(socket.recv_json().await["type"], "stream"); - let samples = jfk_samples(); - send_samples(&mut socket, &samples).await; - send_pcm(&mut socket, 3 * 16_000).await; - let committed = wait_for_committed(&mut socket).await; - send_samples(&mut socket, &samples).await; - socket.send_text("stop").await; - let reply = socket - .recv_until(Duration::from_secs(180), |frame| frame["type"] == "final") - .await; - let text = reply["text"].as_str().expect("final text is a string"); - assert!( - text.starts_with(&committed), - "the final frame opens with the committed prefix: {text:?}" - ); - let tail = text[committed.len()..] - .strip_prefix(' ') - .expect("a single space joins the committed prefix and tail"); - assert!( - tail.to_lowercase().contains("country"), - "the tail contributes its own text: {text:?}" - ); - socket.close().await; -} - -#[tokio::test] -#[ignore = "requires whisper test fixtures (tests/fixtures/)"] -async fn stop_at_a_segment_boundary_returns_the_committed_prefix() { - let server = fixture_server(true); - let mut socket = JsonSocket::connect(&server.ws_url("/stt")).await; - socket.send_text("start").await; - assert_eq!(socket.recv_json().await["type"], "stream"); - send_samples(&mut socket, &jfk_samples()).await; - send_pcm(&mut socket, 3 * 16_000).await; - let committed = wait_for_committed(&mut socket).await; - socket.send_text("stop").await; - let reply = socket - .recv_until(Duration::from_secs(180), |frame| frame["type"] == "final") - .await; - assert_eq!( - reply["text"], committed, - "no uncommitted speech means no tail transcription" - ); - socket.close().await; -} - -#[tokio::test] -#[ignore = "requires whisper test fixtures (tests/fixtures/)"] -async fn interim_frames_keep_committed_text_append_only() { - let server = fixture_server(true); - let mut socket = JsonSocket::connect(&server.ws_url("/stt")).await; - socket.send_text("start").await; - assert_eq!(socket.recv_json().await["type"], "stream"); - let samples = jfk_samples(); - send_samples(&mut socket, &samples).await; - send_pcm(&mut socket, 3 * 16_000).await; - send_samples(&mut socket, &samples).await; - send_pcm(&mut socket, 3 * 16_000).await; - send_samples(&mut socket, &samples).await; - - let mut committed_frames = Vec::new(); - loop { - let frame = socket - .recv_until(Duration::from_secs(120), |frame| frame["type"] == "interim") - .await; - let committed = frame["committed"] - .as_str() - .expect("every interim frame carries a committed string") - .to_owned(); - assert!( - frame["tentative"].is_string(), - "every interim frame carries a tentative string" - ); - let complete = committed.to_lowercase().matches("country").count() >= 3; - committed_frames.push(committed); - if complete { - break; - } - } - for pair in committed_frames.windows(2) { - assert!( - pair[1].starts_with(&pair[0]), - "committed text is append-only: {:?} then {:?}", - pair[0], - pair[1] - ); - } - socket.send_text("stop").await; - let reply = socket - .recv_until(Duration::from_secs(180), |frame| frame["type"] == "final") - .await; - assert!( - reply["text"] - .as_str() - .is_some_and(|text| text.starts_with(committed_frames.last().expect("frames exist"))), - "the assembled transcript opens with the last committed prefix" - ); - socket.close().await; -} diff --git a/crates/gateway-transcribe/AGENTS.md b/crates/gateway-transcribe/AGENTS.md deleted file mode 100644 index c7e3d169..00000000 --- a/crates/gateway-transcribe/AGENTS.md +++ /dev/null @@ -1,8 +0,0 @@ -# gateway-transcribe - -This crate owns the Whisper transcription engine and nothing else: model ownership, the interim and final-pass inference worker threads, energy-based segmentation, silence gating, and the runtime-loaded gateway-whisper-ffi integration. - -- Engine-only ownership. This crate never depends on HTTP, WebSocket, or UI crates, and never on `gateway-stt`, `workshop-server`, or the gateway. Gateway-owned artifact provisioning, route state, and activation live in `gateway-stt`. -- The host configures the engine through `EngineConfig`'s plain values only. Never accept the host's own configuration types: that would be a dependency back on the server. -- Native whisper backends are runtime artifacts. This crate never compiles whisper.cpp or grows platform-backend Cargo features. -- Worker threads own the whisper contexts; callers hand owned sample buffers through channels and await transcripts on oneshots, so blocking inference never touches the tokio executor. Keep it that way. diff --git a/crates/gateway-transcribe/Cargo.toml b/crates/gateway-transcribe/Cargo.toml deleted file mode 100644 index c422a61f..00000000 --- a/crates/gateway-transcribe/Cargo.toml +++ /dev/null @@ -1,36 +0,0 @@ -[package] -name = "gateway-transcribe" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -publish = false - -description = "PromptForge Whisper transcription engine: model ownership, inference workers, segmentation, and silence gating" - -[dependencies] -shared-progress.workspace = true -thiserror.workspace = true -tokio.workspace = true -tracing.workspace = true -gateway-whisper-ffi.workspace = true -# Optional: only the test-fixtures feature decodes the WAV voice fixtures. -hound = { workspace = true, optional = true } - -[features] -default = [] -# Compiles the crate-internal test fixtures and re-exports them to consumers' -# integration-test binaries; enabled for every test build by the self -# dev-dependency below, never by production consumers. -test-fixtures = ["dep:hound"] - -[dev-dependencies] -# The crate dev-depends on itself so every test target builds the library -# with test-fixtures enabled, without gate commands needing a --features flag. -gateway-transcribe = { path = ".", features = ["test-fixtures"] } -tempfile.workspace = true -tokio = { workspace = true, features = ["test-util"] } - -[lints] -workspace = true diff --git a/crates/gateway-transcribe/src/engine.rs b/crates/gateway-transcribe/src/engine.rs deleted file mode 100644 index 6991bf5e..00000000 --- a/crates/gateway-transcribe/src/engine.rs +++ /dev/null @@ -1,399 +0,0 @@ -//! The STT engine driving the interim and final-pass whisper workers. - -use std::path::PathBuf; -use std::time::Duration; - -use gateway_whisper_ffi::WhisperLibrary; -use shared_progress::ProgressHandle; - -use crate::SAMPLE_RATE; -use crate::error::TranscribeError; -use crate::final_pass::FinalTranscriber; -use crate::worker::Transcriber; - -/// Engine construction settings: plain values the host maps from its own -/// configuration type, so the engine never depends back on its host. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct EngineConfig { - /// Path to the provisioned whisper.cpp shared library. - pub library: PathBuf, - /// Path to the GGML/GGUF whisper model for interim (streaming) - /// transcription. - pub interim_model: PathBuf, - /// Path to the whisper model for the pipelined final pass over a take. - /// `None` disables the final pass; the final transcript then comes from - /// the interim model. - pub final_model: Option, - /// Domain terms whisper is biased toward (for example `MCP`, `GGUF`, - /// `Lua`), formatted into a glossary conditioning prompt on both - /// workers. Empty disables biasing. - pub vocabulary: Vec, - /// Seconds of trailing audio each interim pass transcribes. - pub window_seconds: u64, - /// Milliseconds between interim passes while a take is recording. - pub interval_ms: u64, -} - -/// The STT engine: the interim and final-pass whisper workers plus the -/// interim loop's window and cadence, built once at startup from the host's -/// STT configuration. -#[derive(Debug)] -pub struct SttEngine { - transcriber: Transcriber, - final_pass: Option, - gpu_available: bool, - window_samples: usize, - interval: Duration, -} - -impl SttEngine { - /// Loads the interim model, and the final model when configured, each - /// onto a fresh worker thread. - /// - /// # Errors - /// Returns [`TranscribeError::InvalidConfig`] when the window or interval - /// is zero, [`TranscribeError::LoadLibrary`] when the provisioned runtime - /// cannot be opened, [`TranscribeError::LoadModel`] when a model file - /// cannot be loaded, and [`TranscribeError::SpawnWorker`] when a worker - /// thread cannot be started. - pub fn new(config: &EngineConfig) -> Result { - Self::new_with_progress(config, None) - } - - /// [`SttEngine::new`] plus progress reporting: `progress` gains one - /// child per loaded model (`interim`, `final`), each with a byte-counted - /// `prewarm` leaf and an indeterminate `init` leaf completed when the - /// whisper context is ready. Both worker threads prewarm and load in - /// parallel. - /// - /// # Errors - /// Returns [`TranscribeError::InvalidConfig`] when the window or interval - /// is zero, [`TranscribeError::LoadLibrary`] when the provisioned runtime - /// cannot be opened, [`TranscribeError::LoadModel`] when a model file - /// cannot be loaded, and [`TranscribeError::SpawnWorker`] when a worker - /// thread cannot be started. - #[expect( - clippy::needless_pass_by_value, - reason = "the caller hands its leaf handle to the engine, which registers per-model children on it" - )] - pub fn new_with_progress( - config: &EngineConfig, - progress: Option, - ) -> Result { - if config.window_seconds == 0 { - return Err(TranscribeError::InvalidConfig( - "stt.window_seconds must be at least 1".to_string(), - )); - } - if config.interval_ms == 0 { - return Err(TranscribeError::InvalidConfig( - "stt.interval_ms must be at least 1".to_string(), - )); - } - let window_seconds = usize::try_from(config.window_seconds).map_err(|_| { - TranscribeError::InvalidConfig("stt.window_seconds is too large".to_string()) - })?; - let Some(window_samples) = window_seconds.checked_mul(SAMPLE_RATE) else { - return Err(TranscribeError::InvalidConfig( - "stt.window_seconds is too large".to_string(), - )); - }; - require_model_file(&config.interim_model)?; - if let Some(final_model) = &config.final_model { - require_model_file(final_model)?; - } - let library = WhisperLibrary::load(&config.library).map_err(|source| { - TranscribeError::LoadLibrary { - path: config.library.clone(), - source: Box::new(source), - } - })?; - // Route ggml/whisper C logging into tracing before any context is - // created, so the `whisper_cpp=warn` filter covers engine startup. - library.set_log_callback(); - let gpu_available = library.gpu_available().unwrap_or_else(|error| { - tracing::warn!(%error, "could not inspect whisper GPU support"); - false - }); - let interim_progress = progress.as_ref().map(|handle| handle.child("interim", 1.0)); - let final_progress = match (&config.final_model, &progress) { - (Some(_), Some(handle)) => Some(handle.child("final", 1.0)), - _ => None, - }; - // Both workers prewarm and load concurrently; the waits below only - // collect the outcomes, with the interim outcome reported first. - let (transcriber, interim_init) = Transcriber::spawn( - library.clone(), - &config.interim_model, - &config.vocabulary, - interim_progress, - )?; - let final_spawned = match &config.final_model { - None => None, - Some(final_model) => Some(FinalTranscriber::spawn( - library, - final_model, - &config.vocabulary, - final_progress, - )?), - }; - interim_init - .recv() - .map_err(|_| TranscribeError::WorkerGone)??; - let final_pass = match final_spawned { - None => None, - Some((final_transcriber, final_init)) => { - final_init - .recv() - .map_err(|_| TranscribeError::WorkerGone)??; - Some(final_transcriber) - } - }; - Ok(Self { - transcriber, - final_pass, - gpu_available, - window_samples, - interval: Duration::from_millis(config.interval_ms), - }) - } - - /// Whether the final pass is configured. Segmentation and - /// crystallization only happen when it is: without it nothing can - /// crystallize, so the segmenter must not consume audio the interim - /// model still needs. - #[must_use] - pub fn has_final_pass(&self) -> bool { - self.final_pass.is_some() - } - - /// Whether the loaded whisper.cpp runtime reports CUDA or Metal support. - #[must_use] - pub fn gpu_transcription_available(&self) -> bool { - self.gpu_available - } - - /// Whether the final pass is absent. A test seam for the host's startup - /// degradation policy, which drops an unsourced missing final model. - #[cfg(feature = "test-fixtures")] - #[doc(hidden)] - #[must_use] - pub fn final_pass_absent_for_test(&self) -> bool { - !self.has_final_pass() - } - - /// Samples in the sliding interim window. - #[must_use] - pub fn window_samples(&self) -> usize { - self.window_samples - } - - /// Cadence of the interim loop. - #[must_use] - pub fn interval(&self) -> Duration { - self.interval - } - - /// Transcribes one 16 kHz mono f32 buffer, returning the trimmed text. - /// - /// # Errors - /// Returns [`TranscribeError::Inference`] when the model rejects the - /// audio and [`TranscribeError::WorkerGone`] when the worker thread has - /// exited. - pub async fn transcribe(&self, samples: Vec) -> Result { - self.transcriber.transcribe(samples).await - } - - /// Transcribes one independent buffer with the final model. - /// - /// This request does not read or change the active streaming take. - /// - /// # Errors - /// Returns [`TranscribeError::Inference`] when the model rejects the - /// audio and [`TranscribeError::WorkerGone`] when the worker exits. - pub async fn transcribe_final( - &self, - samples: Vec, - ) -> Option> { - match &self.final_pass { - Some(final_pass) => Some(final_pass.transcribe(samples).await), - None => None, - } - } - - /// Starts a new take on the final-pass worker, discarding the previous - /// take's accumulated transcript and installing `on_segment` as the - /// take's completion channel: each background segment's text is sent on - /// it as the segment finishes. A no-op without a final model. - pub fn final_reset(&self, on_segment: std::sync::mpsc::Sender) { - if let Some(final_pass) = &self.final_pass { - final_pass.reset(on_segment); - } - } - - /// Queues a completed speech segment for background final-pass - /// transcription, conditioned on the take's accumulated transcript. A - /// no-op without a final model. - pub fn final_submit(&self, samples: Vec) { - if let Some(final_pass) = &self.final_pass { - final_pass.submit(samples); - } - } - - /// Queues the take's unprocessed tail and awaits the tail's own - /// transcription - not the take's full assembled transcript, which the - /// session already holds as crystallized segment text. The text is - /// empty when the tail is silent or too short to decode (the worker - /// skips those rather than hallucinating). Returns `None` when no - /// final model is configured and the caller should fall back to the - /// interim model. - /// - /// # Errors - /// Returns [`TranscribeError::Inference`] when the model rejects the - /// audio and [`TranscribeError::WorkerGone`] when the worker thread has - /// exited. - pub async fn final_finish(&self, samples: Vec) -> Option> { - match &self.final_pass { - None => None, - Some(final_pass) => Some(final_pass.finish(samples).await), - } - } -} - -fn require_model_file(path: &std::path::Path) -> Result<(), TranscribeError> { - let metadata = std::fs::metadata(path).map_err(|source| TranscribeError::LoadModel { - path: path.to_path_buf(), - source: Box::new(source), - })?; - if metadata.is_file() { - Ok(()) - } else { - Err(TranscribeError::LoadModel { - path: path.to_path_buf(), - source: Box::new(std::io::Error::other("model path is not a file")), - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use crate::fixtures; - - #[tokio::test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - async fn transcribes_known_speech_fixture() { - let config = EngineConfig { - library: fixtures::require_library(), - interim_model: fixtures::require_model(), - window_seconds: 12, - interval_ms: 500, - ..EngineConfig::default() - }; - let engine = SttEngine::new(&config).expect("engine loads the fixture model"); - let text = engine - .transcribe(fixtures::jfk_samples()) - .await - .expect("transcription succeeds"); - assert!( - text.to_lowercase().contains("country"), - "transcript names the fixture's words: {text:?}" - ); - } - - #[test] - fn invalid_stt_config_is_rejected() { - let config = EngineConfig { - window_seconds: 0, - ..EngineConfig::default() - }; - let err = SttEngine::new(&config).expect_err("zero window must fail"); - assert!( - matches!(err, TranscribeError::InvalidConfig(_)), - "expected InvalidConfig, got {err:?}" - ); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn missing_final_model_fails_engine_construction() { - let config = EngineConfig { - library: fixtures::require_library(), - interim_model: fixtures::require_model(), - final_model: Some(PathBuf::from("definitely-missing-final-model.bin")), - window_seconds: 12, - interval_ms: 500, - ..EngineConfig::default() - }; - let err = SttEngine::new(&config).expect_err("a missing final model must fail"); - assert!( - matches!(err, TranscribeError::LoadModel { .. }), - "expected LoadModel, got {err:?}" - ); - assert!( - err.to_string() - .contains("definitely-missing-final-model.bin"), - "error names the path: {err}" - ); - } - - #[tokio::test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - async fn final_pass_entry_points_are_no_ops_without_a_final_model() { - let config = EngineConfig { - library: fixtures::require_library(), - interim_model: fixtures::require_model(), - window_seconds: 12, - interval_ms: 500, - ..EngineConfig::default() - }; - let engine = SttEngine::new(&config).expect("engine loads the fixture model"); - let (segment_tx, _segment_rx) = std::sync::mpsc::channel(); - engine.final_reset(segment_tx); - engine.final_submit(fixtures::jfk_samples()); - assert!( - engine.final_finish(fixtures::jfk_samples()).await.is_none(), - "no final model means the caller falls back" - ); - } - - #[test] - fn missing_model_file_fails_engine_construction() { - let config = EngineConfig { - interim_model: PathBuf::from("definitely-missing-model.bin"), - window_seconds: 12, - interval_ms: 500, - ..EngineConfig::default() - }; - let err = SttEngine::new(&config).expect_err("a missing model must fail"); - assert!( - matches!(err, TranscribeError::LoadModel { .. }), - "expected LoadModel, got {err:?}" - ); - assert!( - err.to_string().contains("definitely-missing-model.bin"), - "error names the path: {err}" - ); - } - - #[test] - fn new_with_progress_without_a_handle_behaves_like_new() { - let config = EngineConfig { - interim_model: PathBuf::from("definitely-missing-model.bin"), - window_seconds: 12, - interval_ms: 500, - ..EngineConfig::default() - }; - let err = - SttEngine::new_with_progress(&config, None).expect_err("a missing model must fail"); - assert!( - matches!(err, TranscribeError::LoadModel { .. }), - "expected LoadModel, got {err:?}" - ); - assert!( - err.to_string().contains("definitely-missing-model.bin"), - "error names the path: {err}" - ); - } -} diff --git a/crates/gateway-transcribe/src/error.rs b/crates/gateway-transcribe/src/error.rs deleted file mode 100644 index 78b3dca6..00000000 --- a/crates/gateway-transcribe/src/error.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! STT engine construction and transcription failures. - -use std::path::PathBuf; - -/// An STT engine construction or transcription failure. -#[derive(Debug, thiserror::Error)] -#[non_exhaustive] -pub enum TranscribeError { - /// The provisioned whisper.cpp shared library could not be loaded. - #[non_exhaustive] - #[error("load whisper library {}", path.display())] - LoadLibrary { - /// Shared library path passed to the platform loader. - path: PathBuf, - /// The underlying loader or symbol-resolution error. - #[source] - source: Box, - }, - - /// The whisper model file could not be loaded. - #[non_exhaustive] - #[error("load whisper model {}", path.display())] - LoadModel { - /// The model path that failed to load. - path: PathBuf, - /// The underlying whisper.cpp error, boxed to hide the dependency. - #[source] - source: Box, - }, - - /// The transcription worker thread could not be started. - #[non_exhaustive] - #[error("spawn transcription worker")] - SpawnWorker(#[source] std::io::Error), - - /// The model rejected an audio window. - #[non_exhaustive] - #[error("transcribe audio window")] - Inference(#[source] Box), - - /// The transcription worker exited while requests were in flight. - #[non_exhaustive] - #[error("transcription worker exited")] - WorkerGone, - - /// The STT engine configuration is invalid. - #[non_exhaustive] - #[error("invalid STT configuration: {0}")] - InvalidConfig(String), -} diff --git a/crates/gateway-transcribe/src/final_pass.rs b/crates/gateway-transcribe/src/final_pass.rs deleted file mode 100644 index 8dce3f9d..00000000 --- a/crates/gateway-transcribe/src/final_pass.rs +++ /dev/null @@ -1,585 +0,0 @@ -//! The final-pass worker: background transcription of completed segments. - -use std::path::Path; - -use gateway_whisper_ffi::{WhisperContext, WhisperLibrary, WhisperState}; -use shared_progress::ProgressHandle; - -use crate::error::TranscribeError; -use crate::prompt::{final_prompt, fit_glossary}; -use crate::worker::{load_state, transcribe_blocking}; -use crate::{GLOSSARY_TOKEN_BUDGET, MIN_WINDOW_SAMPLES, is_silence}; - -/// One take's final-pass state: the large model's whisper context and state -/// plus the take's accumulated transcript, which conditions each new -/// segment so domain vocabulary and phrasing survive segmentation. The -/// glossary prompt (fitted at load from the STT vocabulary) biases every -/// segment toward the configured domain terms. -#[derive(Debug)] -pub(crate) struct FinalPass { - ctx: WhisperContext, - state: WhisperState, - /// The fitted glossary prompt, `None` when no vocabulary is configured. - glossary: Option, - /// Every segment transcript so far, joined by single spaces. - transcript: String, - /// The conditioning prompt used on the most recent segment, kept so - /// tests can observe that conditioning actually happened. - last_prompt: String, -} - -impl FinalPass { - /// Loads the final model from `path` and fits the vocabulary glossary. - /// - /// # Errors - /// Returns [`TranscribeError::LoadModel`] when the model file cannot be - /// loaded. - fn load( - library: &WhisperLibrary, - path: &Path, - vocabulary: &[String], - progress: Option<&ProgressHandle>, - ) -> Result { - let (init_tx, init_rx) = std::sync::mpsc::sync_channel(1); - let Some((ctx, state)) = load_state(library, path, progress, &init_tx) else { - return match init_rx.recv() { - Ok(Err(error)) => Err(error), - // `load_state` reports every outcome on the channel before - // returning `None`, so a disconnected or Ok(()) result here - // means the invariant broke, not a new failure mode. - _ => Err(TranscribeError::WorkerGone), - }; - }; - let glossary = fit_glossary(&ctx, vocabulary, GLOSSARY_TOKEN_BUDGET); - Ok(Self { - ctx, - state, - glossary, - transcript: String::new(), - last_prompt: String::new(), - }) - } - - /// Forgets the previous take's transcript for a new take. - fn reset(&mut self) { - self.transcript.clear(); - self.last_prompt.clear(); - } - - /// The conditioning prompt the most recent segment was transcribed with. - #[cfg(test)] - pub(crate) fn last_prompt(&self) -> &str { - &self.last_prompt - } - - /// The take's accumulated transcript: every segment so far, joined by - /// single spaces. A test-only observation point for the conditioning - /// chain; the workers consume only each segment's own text. - #[cfg(test)] - pub(crate) fn transcript(&self) -> &str { - &self.transcript - } - - /// Transcribes one segment conditioned on the accumulated transcript, - /// appends the result, and returns the segment's own text. Silent or - /// tiny fragments are skipped (whisper hallucinates on them): the - /// accumulated transcript is left unchanged and `None` comes back. - /// - /// # Errors - /// Returns [`TranscribeError::Inference`] when the model rejects the - /// audio; the accumulated transcript is left unchanged. - fn transcribe_segment(&mut self, samples: &[f32]) -> Result, TranscribeError> { - let mut segment = None; - if samples.len() >= MIN_WINDOW_SAMPLES && !is_silence(samples) { - let prompt = final_prompt(&self.ctx, self.glossary.as_deref(), &self.transcript); - let text = transcribe_blocking(&mut self.state, samples, Some(&prompt), false)?; - if !text.is_empty() { - if !self.transcript.is_empty() { - self.transcript.push(' '); - } - self.transcript.push_str(&text); - segment = Some(text); - } - self.last_prompt = prompt; - } - Ok(segment) - } - - /// Transcribes one independent request without reading or changing the - /// active streaming take. - fn transcribe_standalone(&mut self, samples: &[f32]) -> Result { - if samples.len() < MIN_WINDOW_SAMPLES || is_silence(samples) { - return Ok(String::new()); - } - let prompt = final_prompt(&self.ctx, self.glossary.as_deref(), ""); - transcribe_blocking(&mut self.state, samples, Some(&prompt), false) - } -} - -/// A command for the final-pass worker thread. -enum FinalJob { - /// Start a new take, discarding the accumulated transcript and - /// installing the take's segment-completion channel. - Reset { - on_segment: std::sync::mpsc::Sender, - }, - /// Transcribe a completed segment (or the closing tail) and reply with - /// the segment's own text, empty when the fragment was skipped. - /// `notify` marks a background submit, whose segment text is also sent - /// on the take's channel; the closing tail reports only through its - /// reply. - Segment { - samples: Vec, - reply: tokio::sync::oneshot::Sender>, - notify: bool, - }, - /// Transcribe an independent request without touching take state. - Standalone { - samples: Vec, - reply: tokio::sync::oneshot::Sender>, - }, -} - -/// Handle to the final-pass worker thread: the large model transcribing -/// completed segments in the background while a take records. -#[derive(Debug)] -pub(crate) struct FinalTranscriber { - job_tx: Option>, - worker: Option>, -} - -impl FinalTranscriber { - /// Spawns the worker thread, which prewarms and loads the model and then - /// reports the load outcome on the returned channel. The caller waits on - /// the channel, so several workers can load in parallel. - /// - /// # Errors - /// Returns [`TranscribeError::SpawnWorker`] when the thread cannot be - /// started. A model load failure arrives on the returned channel as - /// [`TranscribeError::LoadModel`]. - pub(super) fn spawn( - library: WhisperLibrary, - model_path: &Path, - vocabulary: &[String], - progress: Option, - ) -> Result<(Self, std::sync::mpsc::Receiver>), TranscribeError> - { - let (job_tx, job_rx) = std::sync::mpsc::channel::(); - let (init_tx, init_rx) = std::sync::mpsc::sync_channel(1); - let path = model_path.to_path_buf(); - let vocabulary = vocabulary.to_vec(); - let worker = std::thread::Builder::new() - .name("whisper-final".to_string()) - .spawn(move || { - final_worker_loop( - &library, - &path, - &vocabulary, - progress.as_ref(), - &job_rx, - &init_tx, - ); - }) - .map_err(TranscribeError::SpawnWorker)?; - Ok(( - Self { - job_tx: Some(job_tx), - worker: Some(worker), - }, - init_rx, - )) - } - - /// Starts a new take, installing `on_segment` as the channel each - /// background segment's text is reported on. If the worker is gone the - /// next `finish` reports it. - pub(super) fn reset(&self, on_segment: std::sync::mpsc::Sender) { - if let Some(job_tx) = &self.job_tx { - let _ = job_tx.send(FinalJob::Reset { on_segment }); - } - } - - /// Queues a completed segment for background transcription; the - /// segment's text is reported on the take's channel. - pub(super) fn submit(&self, samples: Vec) { - let (reply, _dropped) = tokio::sync::oneshot::channel(); - if let Some(job_tx) = &self.job_tx { - let _ = job_tx.send(FinalJob::Segment { - samples, - reply, - notify: true, - }); - } - } - - /// Queues the take's tail and awaits the tail's own text, empty when - /// the tail was skipped. Because the channel is FIFO, awaiting this - /// reply also drains every segment submitted earlier in the take. - pub(super) async fn finish(&self, samples: Vec) -> Result { - let (reply, reply_rx) = tokio::sync::oneshot::channel(); - let Some(job_tx) = &self.job_tx else { - return Err(TranscribeError::WorkerGone); - }; - job_tx - .send(FinalJob::Segment { - samples, - reply, - notify: false, - }) - .map_err(|_| TranscribeError::WorkerGone)?; - reply_rx.await.map_err(|_| TranscribeError::WorkerGone)? - } - - /// Transcribes one independent buffer without changing the active take. - pub(super) async fn transcribe(&self, samples: Vec) -> Result { - let (reply, reply_rx) = tokio::sync::oneshot::channel(); - let Some(job_tx) = &self.job_tx else { - return Err(TranscribeError::WorkerGone); - }; - job_tx - .send(FinalJob::Standalone { samples, reply }) - .map_err(|_| TranscribeError::WorkerGone)?; - reply_rx.await.map_err(|_| TranscribeError::WorkerGone)? - } -} - -impl Drop for FinalTranscriber { - fn drop(&mut self) { - // Close the queue before joining so the worker drains prior jobs, - // releases its Whisper context, and cannot overlap a replacement. - drop(self.job_tx.take()); - if let Some(worker) = self.worker.take() { - let _ignored = worker.join(); - } - } -} - -/// The final-pass worker's body: load the model, then process takes' jobs in -/// arrival order until every sender is dropped. -fn final_worker_loop( - library: &WhisperLibrary, - path: &Path, - vocabulary: &[String], - progress: Option<&ProgressHandle>, - job_rx: &std::sync::mpsc::Receiver, - init_tx: &std::sync::mpsc::SyncSender>, -) { - let mut pass = match FinalPass::load(library, path, vocabulary, progress) { - Ok(pass) => { - let _ = init_tx.send(Ok(())); - pass - } - Err(error) => { - let _ = init_tx.send(Err(error)); - return; - } - }; - // The current take's completion channel, installed by each `Reset`; - // FIFO job order guarantees a take's segments all precede the next - // take's `Reset`, so a segment can never land on the wrong channel. - let mut on_segment: Option> = None; - while let Ok(job) = job_rx.recv() { - match job { - FinalJob::Reset { - on_segment: channel, - } => { - on_segment = Some(channel); - pass.reset(); - } - FinalJob::Segment { - samples, - reply, - notify, - } => { - let result = pass.transcribe_segment(&samples); - match &result { - Ok(segment) => { - if notify && let (Some(channel), Some(text)) = (&on_segment, segment) { - // A gone session (socket closed mid-take) is - // ordinary; the transcript was computed anyway. - if channel.send(text.clone()).is_err() { - tracing::debug!("segment completion receiver is gone"); - } - } - } - Err(error) => { - tracing::warn!(%error, "final-pass segment transcription failed"); - } - } - // A dropped receiver (a background segment, or a session - // closed mid-take) is fine: the transcript was computed. - let _ = reply.send(result.map(Option::unwrap_or_default)); - } - FinalJob::Standalone { samples, reply } => { - let result = pass.transcribe_standalone(&samples); - if let Err(error) = &result { - tracing::warn!(%error, "standalone final-model transcription failed"); - } - let _ = reply.send(result); - } - } - } -} - -#[cfg(test)] -mod tests { - use std::time::Duration; - - use super::*; - - use crate::engine::SttEngine; - use crate::{EngineConfig, SAMPLE_RATE, fixtures}; - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn final_pass_biases_segments_with_the_glossary() { - let vocabulary: Vec = ["MCP", "GGUF"].map(str::to_string).into(); - let library = fixtures::require_loaded_library(); - let mut pass = FinalPass::load(&library, &fixtures::require_model(), &vocabulary, None) - .expect("final pass loads the fixture model"); - let first = pass - .transcribe_segment(&fixtures::jfk_samples()) - .expect("segment one transcribes") - .expect("segment one appended text"); - assert!( - first.to_lowercase().contains("country"), - "segment one names the fixture's words: {first:?}" - ); - assert!( - pass.last_prompt().starts_with("Glossary: MCP, GGUF."), - "the first segment was conditioned on the glossary: {:?}", - pass.last_prompt() - ); - let second = pass - .transcribe_segment(&fixtures::jfk_samples()) - .expect("segment two transcribes") - .expect("segment two appended text"); - assert!( - second.to_lowercase().contains("country"), - "segment two names the fixture's words: {second:?}" - ); - let prompt = pass.last_prompt(); - assert!( - prompt.starts_with("Glossary: MCP, GGUF. "), - "the glossary leads the conditioning prompt: {prompt:?}" - ); - assert!( - prompt.contains(&first), - "the transcript follows the glossary: {prompt:?}" - ); - } - - #[tokio::test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - async fn final_submit_reports_the_segment_on_the_take_channel() { - let config = EngineConfig { - library: fixtures::require_library(), - interim_model: fixtures::require_model(), - final_model: Some(fixtures::require_model()), - window_seconds: 12, - interval_ms: 500, - ..EngineConfig::default() - }; - let engine = SttEngine::new(&config).expect("engine loads the fixture model"); - let (segment_tx, segment_rx) = std::sync::mpsc::channel(); - engine.final_reset(segment_tx); - engine.final_submit(fixtures::jfk_samples()); - - // The timeout only bounds a broken pipeline; the tiny fixture - // model transcribes the clip in seconds. - let segment = segment_rx - .recv_timeout(Duration::from_secs(120)) - .expect("the submitted segment's text arrives on the channel"); - assert!( - segment.to_lowercase().contains("country"), - "the reported segment names the fixture's words: {segment:?}" - ); - - let tail = engine - .final_finish(fixtures::jfk_samples()) - .await - .expect("a final model is configured") - .expect("the final pass succeeds"); - assert!( - tail.to_lowercase().contains("country"), - "the closing tail names the fixture's words: {tail:?}" - ); - let countries = tail.to_lowercase().matches("country").count(); - assert!( - countries < 3, - "the finish returns the tail's text only, not the assembled \ - transcript ({countries} countries): {tail:?}" - ); - assert!( - segment_rx.try_recv().is_err(), - "the closing tail reports only through its reply, not the channel" - ); - } - - #[tokio::test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - async fn final_finish_with_a_silent_tail_returns_empty_after_draining() { - let config = EngineConfig { - library: fixtures::require_library(), - interim_model: fixtures::require_model(), - final_model: Some(fixtures::require_model()), - window_seconds: 12, - interval_ms: 500, - ..EngineConfig::default() - }; - let engine = SttEngine::new(&config).expect("engine loads the fixture model"); - let (segment_tx, segment_rx) = std::sync::mpsc::channel(); - engine.final_reset(segment_tx); - engine.final_submit(fixtures::jfk_samples()); - - // The tail is pure silence: the worker skips it rather than - // hallucinating, and the FIFO reply still drains the take's - // submitted segment first. - let tail = engine - .final_finish(vec![0.0; SAMPLE_RATE]) - .await - .expect("a final model is configured") - .expect("the final pass succeeds"); - assert!( - tail.is_empty(), - "a silent tail is skipped, not transcribed: {tail:?}" - ); - let segment = segment_rx - .recv_timeout(Duration::from_secs(120)) - .expect("the submitted segment's text arrives on the channel"); - assert!( - segment.to_lowercase().contains("country"), - "the drained segment names the fixture's words: {segment:?}" - ); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn final_pass_conditions_each_segment_on_the_accumulated_transcript() { - let library = fixtures::require_loaded_library(); - let mut pass = FinalPass::load(&library, &fixtures::require_model(), &[], None) - .expect("final pass loads the fixture model"); - let jfk = fixtures::jfk_samples(); - - let first = pass - .transcribe_segment(&jfk) - .expect("segment one transcribes") - .expect("segment one appended text"); - assert!( - pass.last_prompt().is_empty(), - "the first segment has nothing to be conditioned on" - ); - let first_lower = first.to_lowercase(); - assert!( - first_lower.contains("country"), - "segment one names the fixture's words: {first:?}" - ); - let first_countries = first_lower.matches("country").count(); - assert_eq!( - pass.transcript(), - first, - "the accumulated transcript is the first segment's text" - ); - - let second = pass - .transcribe_segment(&jfk) - .expect("segment two transcribes") - .expect("segment two appended text"); - assert_eq!( - pass.last_prompt(), - first, - "segment two was conditioned on the accumulated transcript" - ); - assert!( - second.to_lowercase().contains("country"), - "the segment's own text names the fixture's words: {second:?}" - ); - let assembled = pass.transcript(); - assert!( - assembled.starts_with(&first), - "segment transcripts accumulate in order: {assembled:?}" - ); - let second_countries = assembled.to_lowercase().matches("country").count(); - assert!( - second_countries > first_countries, - "the second segment added its own text: {first_countries} then {second_countries}" - ); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn final_pass_reset_forgets_the_accumulated_transcript() { - let library = fixtures::require_loaded_library(); - let mut pass = FinalPass::load(&library, &fixtures::require_model(), &[], None) - .expect("final pass loads the fixture model"); - let jfk = fixtures::jfk_samples(); - - let first = pass - .transcribe_segment(&jfk) - .expect("segment one transcribes") - .expect("segment one appended text"); - pass.reset(); - let second = pass - .transcribe_segment(&jfk) - .expect("segment two transcribes") - .expect("segment two appended text"); - assert!( - pass.last_prompt().is_empty(), - "after reset the next segment has nothing to be conditioned on" - ); - assert_eq!( - second, first, - "a new take's transcript holds only its own segments" - ); - assert_eq!( - pass.transcript(), - second, - "the accumulated transcript forgot the previous take" - ); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn standalone_transcription_does_not_change_the_streaming_take() { - let library = fixtures::require_loaded_library(); - let mut pass = FinalPass::load(&library, &fixtures::require_model(), &[], None) - .expect("final pass loads the fixture model"); - let jfk = fixtures::jfk_samples(); - let _first = pass - .transcribe_segment(&jfk) - .expect("streaming segment transcribes") - .expect("streaming segment has text"); - let transcript = pass.transcript().to_owned(); - let last_prompt = pass.last_prompt().to_owned(); - let standalone = pass - .transcribe_standalone(&jfk) - .expect("standalone request transcribes"); - assert!(standalone.to_lowercase().contains("country")); - assert_eq!( - pass.transcript(), - transcript, - "request-response transcription cannot change streaming take state" - ); - assert_eq!(pass.last_prompt(), last_prompt); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn final_pass_skips_silence_without_touching_the_transcript() { - let library = fixtures::require_loaded_library(); - let mut pass = FinalPass::load(&library, &fixtures::require_model(), &[], None) - .expect("final pass loads the fixture model"); - let segment = pass - .transcribe_segment(&vec![0.0; SAMPLE_RATE * 2]) - .expect("silence is skipped, not an error"); - assert!(segment.is_none(), "a skipped segment reports no text"); - assert!( - pass.transcript().is_empty(), - "silence transcribes to nothing" - ); - assert!( - pass.last_prompt().is_empty(), - "a skipped segment records no conditioning" - ); - } -} diff --git a/crates/gateway-transcribe/src/lib.rs b/crates/gateway-transcribe/src/lib.rs deleted file mode 100644 index 88c4991d..00000000 --- a/crates/gateway-transcribe/src/lib.rs +++ /dev/null @@ -1,237 +0,0 @@ -//! Whisper transcription on dedicated worker threads. -//! -//! [`SttEngine`] owns two worker threads: the interim worker holds the -//! streaming model and transcribes sliding windows, and the final-pass -//! worker (`FinalTranscriber`, present when [`EngineConfig::final_model`] is -//! set) holds the larger model and transcribes completed speech segments in -//! the background while the user is still talking. Callers hand owned sample -//! buffers through channels and await transcripts on oneshots, so the -//! blocking CPU-bound inference never touches the tokio executor. The pure -//! helpers ([`is_silence`], [`tail`]) are the session's silence gate: -//! whisper hallucinates plausible text on silent input, so quiet windows are -//! never sent to the model. - -mod engine; -mod error; -mod final_pass; -mod prompt; -mod segment; -mod slot; -mod worker; - -pub use engine::{EngineConfig, SttEngine}; -pub use error::TranscribeError; -pub use segment::Segmenter; -pub use slot::SttSlot; - -/// PCM sample rate the streaming wire format and whisper both require. -pub const SAMPLE_RATE: usize = 16_000; - -/// Windows below this RMS are treated as silence and never transcribed. -/// -/// 0.001 is -60 dBFS: above the noise floor of a browser-suppressed mic -/// stream, far below conversational speech (typically 0.02 and up). -const SILENCE_RMS: f64 = 0.001; - -/// Minimum audio the interim loop bothers to transcribe; shorter fragments -/// decode to garbage often enough that gating them is cheaper than filtering -/// their output. -pub const MIN_WINDOW_SAMPLES: usize = SAMPLE_RATE / 2; - -/// Maximum conditioning prompt handed to the final pass, in chars. Whisper -/// keeps at most half its text context for the prompt (224 tokens), and -/// four chars per token is a conservative English estimate; the tail of the -/// accumulated transcript is what matters for continuity, so the cap trims -/// from the front. -const MAX_PROMPT_CHARS: usize = 800; - -/// Whisper's prompt budget in tokens: half the text context -/// (`whisper_n_text_ctx / 2`). A prompt longer than this is truncated by -/// whisper.cpp from the front, which would silently drop a glossary -/// prefix, so prompts are fitted to the budget before being set. -const MAX_PROMPT_TOKENS: usize = 224; - -/// Token budget for the glossary on the final-pass worker; the rest of the -/// prompt budget is reserved for the segment-conditioning transcript. The -/// interim worker passes no transcript and fits its glossary to the full -/// budget. -const GLOSSARY_TOKEN_BUDGET: usize = MAX_PROMPT_TOKENS / 2; - -/// Root-mean-square amplitude of a PCM buffer. -#[expect( - clippy::cast_precision_loss, - reason = "audio buffers are far below 2^53 samples" -)] -fn rms(samples: &[f32]) -> f64 { - if samples.is_empty() { - return 0.0; - } - let energy: f64 = samples.iter().map(|&s| f64::from(s) * f64::from(s)).sum(); - (energy / samples.len() as f64).sqrt() -} - -/// Returns true when the buffer is quiet enough that whisper would -/// hallucinate rather than transcribe. -#[must_use] -pub fn is_silence(samples: &[f32]) -> bool { - rms(samples) < SILENCE_RMS -} - -/// Returns the trailing `window` samples of `buffer`, or the whole buffer -/// when it is shorter than the window. -#[must_use] -pub fn tail(buffer: &[f32], window: usize) -> &[f32] { - &buffer[buffer.len().saturating_sub(window)..] -} - -/// Shared fixtures for the transcription tests: a small GGML whisper model -/// and a 16 kHz mono WAV of known speech, both downloaded out of band (the -/// URLs are recorded in the design log) and gitignored. Gated on the -/// `test-fixtures` feature - which the crate's own dev-dependency enables -/// for every test build - rather than `cfg(test)`, so consumers' -/// integration-test binaries reuse these through their own fixture -/// re-exports instead of duplicating them. -// An `allow` rather than an `expect`: whether the lint fires here depends -// on the build's cfg permutation (clippy suppresses expect_used inside -// test-cfg'd code on its own), so an expectation would be unfulfilled in -// some builds and fail the -D warnings gate. -#[cfg(feature = "test-fixtures")] -#[doc(hidden)] -#[allow( - clippy::expect_used, - reason = "test fixtures fail by panicking with the invariant named" -)] -pub mod fixtures { - use std::path::{Path, PathBuf}; - - /// The directory holding the downloaded fixtures. - #[must_use] - pub fn fixture_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") - } - - /// Path to the test model, `ggml-tiny.en.bin`. - #[must_use] - pub fn model_path() -> PathBuf { - fixture_dir().join("ggml-tiny.en.bin") - } - - /// Path to the test model, panicking with download instructions when it - /// has not been fetched. - /// - /// # Panics - /// Panics when the model file has not been downloaded, naming the URL - /// and the destination directory. - #[must_use] - pub fn require_model() -> PathBuf { - let path = - std::env::var_os("PROMPTFORGE_WHISPER_MODEL").map_or_else(model_path, PathBuf::from); - assert!( - path.is_file(), - "test model missing: download ggml-tiny.en.bin from \ - https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin \ - into {}", - fixture_dir().display() - ); - path - } - - /// Path to the packaged whisper.cpp shared-library test fixture. - /// - /// # Panics - /// Panics when `PROMPTFORGE_WHISPER_LIBRARY` is unset or does not name a - /// file. - #[must_use] - pub fn require_library() -> PathBuf { - let path = std::env::var_os("PROMPTFORGE_WHISPER_LIBRARY") - .map(PathBuf::from) - .unwrap_or_default(); - assert!( - path.is_file(), - "set PROMPTFORGE_WHISPER_LIBRARY to the packaged whisper shared library" - ); - path - } - - /// Loads the packaged whisper.cpp test library. - /// - /// # Panics - /// Panics when the fixture is absent or the platform loader rejects it. - #[must_use] - pub fn require_loaded_library() -> gateway_whisper_ffi::WhisperLibrary { - gateway_whisper_ffi::WhisperLibrary::load(&require_library()) - .expect("whisper test library loads") - } - - /// Loads the packaged test library and tiny-model context. - /// - /// # Panics - /// Panics when either fixture is absent or whisper rejects the model. - #[must_use] - pub fn require_context() -> ( - gateway_whisper_ffi::WhisperLibrary, - gateway_whisper_ffi::WhisperContext, - ) { - let library = require_loaded_library(); - let context = gateway_whisper_ffi::WhisperContext::new(&library, &require_model()) - .expect("fixture model loads"); - (library, context) - } - - /// Decodes `jfk.wav` (16 kHz mono s16 PCM, "ask not what your country - /// can do for you") into f32 samples for the wire format. - /// - /// # Panics - /// Panics when the fixture WAV is missing or is not 16 kHz mono s16 - /// PCM. - #[must_use] - pub fn jfk_samples() -> Vec { - let path = std::env::var_os("PROMPTFORGE_WHISPER_AUDIO") - .map_or_else(|| fixture_dir().join("jfk.wav"), PathBuf::from); - let mut reader = - hound::WavReader::open(&path).expect("jfk.wav fixture exists beside the test model"); - let spec = reader.spec(); - assert_eq!(spec.sample_rate, 16_000, "fixture must be 16 kHz"); - assert_eq!(spec.channels, 1, "fixture must be mono"); - assert_eq!(spec.bits_per_sample, 16, "fixture must be 16-bit PCM"); - let samples: Vec = reader - .samples::() - .collect::>() - .expect("fixture decodes as s16 PCM"); - samples - .into_iter() - .map(|sample| f32::from(sample) / 32_768.0) - .collect() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn rms_of_silence_is_zero() { - assert_eq!(rms(&[]).to_bits(), 0.0f64.to_bits()); - assert_eq!(rms(&[0.0; 1600]).to_bits(), 0.0f64.to_bits()); - } - - #[test] - fn rms_of_a_constant_signal_is_its_amplitude() { - assert!((rms(&[0.5; 100]) - 0.5).abs() < 1e-9); - } - - #[test] - fn silence_gate_separates_quiet_from_speech() { - assert!(is_silence(&[0.0; 1600])); - assert!(is_silence(&[0.0005; 1600])); - assert!(!is_silence(&[0.05; 1600])); - } - - #[test] - fn tail_returns_the_trailing_window() { - let buffer: Vec = (0u8..10).map(f32::from).collect(); - assert_eq!(tail(&buffer, 4), &[6.0, 7.0, 8.0, 9.0]); - assert_eq!(tail(&buffer, 100), &buffer[..]); - assert_eq!(tail(&[], 4), &[] as &[f32]); - } -} diff --git a/crates/gateway-transcribe/src/prompt.rs b/crates/gateway-transcribe/src/prompt.rs deleted file mode 100644 index 2e762cc6..00000000 --- a/crates/gateway-transcribe/src/prompt.rs +++ /dev/null @@ -1,245 +0,0 @@ -//! Whisper conditioning prompts: glossary fitting and transcript tails. - -use gateway_whisper_ffi::WhisperContext; - -use crate::{MAX_PROMPT_CHARS, MAX_PROMPT_TOKENS}; - -/// The trailing `max` bytes of `text`, cut at a char boundary. -fn tail_chars(text: &str, max: usize) -> &str { - let mut start = text.len().saturating_sub(max); - while !text.is_char_boundary(start) { - start += 1; - } - &text[start..] -} - -/// The trailing `MAX_PROMPT_CHARS` chars of `prompt` with null bytes -/// stripped: whisper's prompt buffer is bounded, and `set_initial_prompt` -/// panics on null bytes, which a model transcript could in principle -/// contain. -pub(super) fn sanitize_prompt(prompt: &str) -> String { - let cleaned: String = prompt.chars().filter(|&c| c != '\0').collect(); - tail_chars(&cleaned, MAX_PROMPT_CHARS).to_string() -} - -/// Formats `vocabulary` as a whisper conditioning prompt in glossary form: -/// `Glossary: a, b, c.` Terms are trimmed and null bytes stripped (whisper -/// tokenization rejects them); a vocabulary with no usable terms yields -/// `None`. The glossary format is a soft probabilistic bias, and measurably -/// outperforms a raw keyword list. -pub(crate) fn glossary_prompt(vocabulary: &[String]) -> Option { - let terms: Vec = vocabulary - .iter() - .map(|term| { - term.trim() - .chars() - .filter(|&c| c != '\0') - .collect::() - }) - .filter(|term| !term.is_empty()) - .collect(); - if terms.is_empty() { - return None; - } - Some(format!("Glossary: {}.", terms.join(", "))) -} - -/// Token count of `text` under the model's tokenizer, or `usize::MAX` -/// when tokenization fails (for example on null bytes, though callers -/// strip those first). -/// -/// Tokenizing with one slot per byte - an upper bound on the token count - -/// means the native buffer always has enough capacity. -fn token_count(ctx: &WhisperContext, text: &str) -> usize { - ctx.tokenize(text, text.len().max(1)) - .map_or(usize::MAX, |tokens| tokens.len()) -} - -/// Fits the glossary prompt for `vocabulary` within `budget` whisper tokens -/// (and the prompt char cap), dropping whole terms from the end until it -/// fits. Returns `None` when the vocabulary has no usable terms or no term -/// fits, and logs a warning when terms were dropped. -pub(super) fn fit_glossary( - ctx: &WhisperContext, - vocabulary: &[String], - budget: usize, -) -> Option { - let mut len = vocabulary.len(); - let mut fitted = glossary_prompt(vocabulary)?; - while fitted.len() > MAX_PROMPT_CHARS || token_count(ctx, &fitted) > budget { - len -= 1; - if len == 0 { - tracing::warn!("no voice vocabulary term fits the prompt budget"); - return None; - } - fitted = glossary_prompt(&vocabulary[..len])?; - } - if len < vocabulary.len() { - tracing::warn!( - kept = len, - dropped = vocabulary.len() - len, - "voice vocabulary truncated to fit whisper's prompt budget" - ); - } - Some(fitted) -} - -/// Builds the final pass's conditioning prompt: the fitted glossary -/// followed by as much of the accumulated transcript's tail as fits within -/// the char cap and whisper's 224-token prompt budget. The transcript trims -/// from the front (its tail carries the continuity); the glossary is never -/// trimmed here - it was fitted to its own budget at load. -pub(super) fn final_prompt( - ctx: &WhisperContext, - glossary: Option<&str>, - transcript: &str, -) -> String { - let Some(glossary) = glossary else { - return sanitize_prompt(transcript); - }; - let cleaned: String = transcript.chars().filter(|&c| c != '\0').collect(); - let char_budget = MAX_PROMPT_CHARS.saturating_sub(glossary.len() + 1); - let mut tail = tail_chars(&cleaned, char_budget).trim_start(); - loop { - if tail.is_empty() { - return glossary.to_string(); - } - let combined = format!("{glossary} {tail}"); - if token_count(ctx, &combined) <= MAX_PROMPT_TOKENS { - return combined; - } - // Drop the tail's first word and retry; a single oversized word is - // dropped whole, which ends the loop on the next iteration. - tail = match tail.find(char::is_whitespace) { - Some(index) => tail[index..].trim_start(), - None => "", - }; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use crate::{GLOSSARY_TOKEN_BUDGET, fixtures}; - - #[test] - fn sanitize_prompt_strips_nulls_and_caps_length() { - assert_eq!(sanitize_prompt("hello"), "hello"); - assert_eq!(sanitize_prompt("a\0b"), "ab"); - let long = "x".repeat(MAX_PROMPT_CHARS + 100); - assert_eq!(sanitize_prompt(&long).len(), MAX_PROMPT_CHARS); - // Multibyte input is capped at a char boundary, never mid-codepoint. - let multibyte = "é".repeat(MAX_PROMPT_CHARS + 10); - let capped = sanitize_prompt(&multibyte); - assert!(capped.len() <= MAX_PROMPT_CHARS); - assert!(capped.chars().all(|c| c == 'é')); - } - - #[test] - fn glossary_prompt_is_none_without_usable_terms() { - assert_eq!(glossary_prompt(&[]), None); - assert_eq!(glossary_prompt(&[String::new()]), None); - assert_eq!(glossary_prompt(&[" ".to_string()]), None); - assert_eq!(glossary_prompt(&["\0".to_string()]), None); - } - - #[test] - fn glossary_prompt_formats_a_glossary() { - let vocabulary: Vec = ["MCP", "GGUF", "Lua"].map(str::to_string).into(); - assert_eq!( - glossary_prompt(&vocabulary), - Some("Glossary: MCP, GGUF, Lua.".to_string()) - ); - } - - #[test] - fn glossary_prompt_cleans_terms() { - let vocabulary: Vec = [" tokio ", "ax\0um", ""].map(str::to_string).into(); - assert_eq!( - glossary_prompt(&vocabulary), - Some("Glossary: tokio, axum.".to_string()) - ); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn fit_glossary_keeps_a_vocabulary_that_fits() { - let (_library, ctx) = fixtures::require_context(); - let vocabulary: Vec = ["MCP", "GGUF", "Lua"].map(str::to_string).into(); - let fitted = - fit_glossary(&ctx, &vocabulary, GLOSSARY_TOKEN_BUDGET).expect("a short glossary fits"); - assert_eq!(fitted, "Glossary: MCP, GGUF, Lua."); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn fit_glossary_drops_terms_from_the_end_to_fit() { - let (_library, ctx) = fixtures::require_context(); - let mut vocabulary: Vec = ["MCP".to_string()].into(); - for index in 0..200 { - vocabulary.push(format!("internationalization{index}")); - } - let fitted = fit_glossary(&ctx, &vocabulary, GLOSSARY_TOKEN_BUDGET) - .expect("the leading terms still fit"); - assert!( - fitted.starts_with("Glossary: MCP, "), - "truncation keeps the leading terms: {fitted:?}" - ); - assert!( - fitted.len() <= MAX_PROMPT_CHARS, - "the fitted glossary respects the char cap" - ); - assert!( - token_count(&ctx, &fitted) <= GLOSSARY_TOKEN_BUDGET, - "the fitted glossary tokenizes within its budget: {fitted:?}" - ); - let kept = fitted.matches(", ").count(); - assert!( - kept < vocabulary.len(), - "terms were dropped to fit: {kept} of {}", - vocabulary.len() - ); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn final_prompt_without_a_glossary_matches_sanitize() { - let (_library, ctx) = fixtures::require_context(); - let transcript = "the quick brown fox ".repeat(100); - assert_eq!( - final_prompt(&ctx, None, &transcript), - sanitize_prompt(&transcript) - ); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn final_prompt_prepends_the_glossary_and_caps_tokens() { - let (_library, ctx) = fixtures::require_context(); - let glossary = "Glossary: MCP, GGUF, Lua."; - assert_eq!( - final_prompt(&ctx, Some(glossary), ""), - glossary, - "an empty transcript leaves the glossary alone" - ); - let transcript = "the quick brown fox jumps over the lazy dog ".repeat(100); - let prompt = final_prompt(&ctx, Some(glossary), &transcript); - assert!( - prompt.starts_with(glossary), - "the glossary leads the prompt: {prompt:?}" - ); - assert!( - prompt.len() <= MAX_PROMPT_CHARS, - "the combined prompt respects the char cap" - ); - assert!( - token_count(&ctx, &prompt) <= MAX_PROMPT_TOKENS, - "the combined prompt tokenizes within whisper's budget" - ); - assert!( - prompt.contains("lazy dog"), - "the transcript's tail survives the trim: {prompt:?}" - ); - } -} diff --git a/crates/gateway-transcribe/src/slot.rs b/crates/gateway-transcribe/src/slot.rs deleted file mode 100644 index d3bd771f..00000000 --- a/crates/gateway-transcribe/src/slot.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! Shared slot for the active STT engine. - -use std::sync::{Arc, PoisonError, RwLock}; - -use crate::engine::SttEngine; - -/// Shared holder for the active STT engine. -/// -/// Reads happen per request and writes happen on profile switches, so a -/// standard [`RwLock`] suffices. No guard crosses an `.await`, and lock -/// poisoning recovers the value so a panicking peer cannot wedge STT for -/// the process lifetime. -#[derive(Debug, Clone, Default)] -pub struct SttSlot { - engine: Arc>>>, -} - -impl SttSlot { - /// The engine, when it has loaded. - #[must_use] - pub fn engine(&self) -> Option> { - self.engine - .read() - .unwrap_or_else(PoisonError::into_inner) - .clone() - } - - /// Whether the engine has loaded. - #[must_use] - pub fn is_active(&self) -> bool { - self.engine - .read() - .unwrap_or_else(PoisonError::into_inner) - .is_some() - } - - /// Installs a loaded engine. - pub fn activate(&self, engine: SttEngine) { - *self.engine.write().unwrap_or_else(PoisonError::into_inner) = Some(Arc::new(engine)); - } - - /// Removes and drops the active engine. - /// - /// Returns whether an engine was active. - #[must_use] - pub fn deactivate(&self) -> bool { - self.take().is_some() - } - - /// Removes and returns the active engine. - /// - /// The runtime uses the returned strong handle to wait until route - /// borrowers release the engine before loading replacement model memory. - #[must_use] - pub fn take(&self) -> Option> { - self.engine - .write() - .unwrap_or_else(PoisonError::into_inner) - .take() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn an_empty_slot_deactivates_without_work() { - let slot = SttSlot::default(); - assert!(!slot.is_active()); - assert!(!slot.deactivate()); - assert!(!slot.is_active()); - } -} diff --git a/crates/gateway-transcribe/src/worker.rs b/crates/gateway-transcribe/src/worker.rs deleted file mode 100644 index 7f44c7ba..00000000 --- a/crates/gateway-transcribe/src/worker.rs +++ /dev/null @@ -1,317 +0,0 @@ -//! The interim whisper worker thread and the shared blocking inference pass. - -use std::io::Read; -use std::path::Path; - -use gateway_whisper_ffi::{ - FullParams, SamplingStrategy, WhisperContext, WhisperLibrary, WhisperState, -}; -use shared_progress::ProgressHandle; - -use crate::MAX_PROMPT_TOKENS; -use crate::error::TranscribeError; -use crate::prompt::{fit_glossary, sanitize_prompt}; - -/// Chunk size for the prewarm read: large enough to bound syscall count on -/// multi-GiB models, small enough that `set_units` moves visibly. -const PREWARM_CHUNK: usize = 4 * 1024 * 1024; - -/// One transcription request handed to the worker thread. -struct Job { - samples: Vec, - reply: tokio::sync::oneshot::Sender>, -} - -/// Handle to the whisper worker thread. -#[derive(Debug)] -pub(crate) struct Transcriber { - job_tx: Option>, - worker: Option>, -} - -impl Transcriber { - /// Spawns the worker thread, which prewarms and loads the model and then - /// reports the load outcome on the returned channel. The caller waits on - /// the channel, so several workers can load in parallel. - /// - /// # Errors - /// Returns [`TranscribeError::SpawnWorker`] when the thread cannot be - /// started. A model load failure arrives on the returned channel as - /// [`TranscribeError::LoadModel`]. - pub(super) fn spawn( - library: WhisperLibrary, - model_path: &Path, - vocabulary: &[String], - progress: Option, - ) -> Result<(Self, std::sync::mpsc::Receiver>), TranscribeError> - { - let (job_tx, job_rx) = std::sync::mpsc::channel::(); - let (init_tx, init_rx) = std::sync::mpsc::sync_channel(1); - let path = model_path.to_path_buf(); - let vocabulary = vocabulary.to_vec(); - let worker = std::thread::Builder::new() - .name("whisper-transcribe".to_string()) - .spawn(move || { - worker_loop( - &library, - &path, - &vocabulary, - progress.as_ref(), - &job_rx, - &init_tx, - ); - }) - .map_err(TranscribeError::SpawnWorker)?; - Ok(( - Self { - job_tx: Some(job_tx), - worker: Some(worker), - }, - init_rx, - )) - } - - /// Queues `samples` for transcription and awaits the trimmed text. - pub(super) async fn transcribe(&self, samples: Vec) -> Result { - let (reply, reply_rx) = tokio::sync::oneshot::channel(); - let Some(job_tx) = &self.job_tx else { - return Err(TranscribeError::WorkerGone); - }; - job_tx - .send(Job { samples, reply }) - .map_err(|_| TranscribeError::WorkerGone)?; - reply_rx.await.map_err(|_| TranscribeError::WorkerGone)? - } -} - -impl Drop for Transcriber { - fn drop(&mut self) { - // Close the queue before joining so the worker exits after any - // in-progress inference and releases its Whisper context. - drop(self.job_tx.take()); - if let Some(worker) = self.worker.take() { - let _ignored = worker.join(); - } - } -} - -/// The worker thread's body: load the model, fit the glossary prompt, then -/// transcribe jobs in arrival order until every sender is dropped. -fn worker_loop( - library: &WhisperLibrary, - path: &Path, - vocabulary: &[String], - progress: Option<&ProgressHandle>, - job_rx: &std::sync::mpsc::Receiver, - init_tx: &std::sync::mpsc::SyncSender>, -) { - let Some((ctx, mut state)) = load_state(library, path, progress, init_tx) else { - return; - }; - // The interim pass carries no transcript, so the glossary gets the full - // prompt budget. - let glossary = fit_glossary(&ctx, vocabulary, MAX_PROMPT_TOKENS); - while let Ok(job) = job_rx.recv() { - // The receiver may be gone (session closed mid-pass); the transcript - // is computed anyway and the send failure ignored. - let _ = job.reply.send(transcribe_blocking( - &mut state, - &job.samples, - glossary.as_deref(), - true, - )); - } -} - -/// Loads a whisper context and state from `path`, reporting the outcome on -/// `init_tx` (which the spawner blocks on). Returns `None` after reporting a -/// failure, or when the spawner is already gone. -pub(super) fn load_state( - library: &WhisperLibrary, - path: &Path, - progress: Option<&ProgressHandle>, - init_tx: &std::sync::mpsc::SyncSender>, -) -> Option<(WhisperContext, WhisperState)> { - let loaded = load_context(library, path, progress); - match loaded { - Ok(pair) => { - let _ = init_tx.send(Ok(())); - Some(pair) - } - Err(error) => { - let _ = init_tx.send(Err(error)); - None - } - } -} - -/// Prewarms the model file, then loads the whisper context and state. The -/// byte-counted prewarm and the indeterminate whisper/CUDA init report as -/// sibling leaves under `progress`. -fn load_context( - library: &WhisperLibrary, - path: &Path, - progress: Option<&ProgressHandle>, -) -> Result<(WhisperContext, WhisperState), TranscribeError> { - let prewarm_leaf = progress.map(|handle| handle.child("prewarm", 1.0)); - prewarm(path, prewarm_leaf.as_ref())?; - let init_leaf = progress.map(|handle| handle.child("init", 1.0)); - let ctx = WhisperContext::new(library, path).map_err(|source| TranscribeError::LoadModel { - path: path.to_path_buf(), - source: Box::new(source), - })?; - let state = ctx - .create_state() - .map_err(|source| TranscribeError::LoadModel { - path: path.to_path_buf(), - source: Box::new(source), - })?; - if let Some(leaf) = &init_leaf { - leaf.complete(); - } - Ok((ctx, state)) -} - -/// Reads `path` sequentially through a reused buffer so the model file sits -/// in the page cache before whisper maps it, reporting bytes read on -/// `progress`. Unconditional: the engine only runs on machines with memory -/// for the models it loads, so the thrash case is excluded by design. -/// -/// # Errors -/// Returns [`TranscribeError::LoadModel`] naming `path` when the file -/// cannot be statted, opened, or read. -fn prewarm(path: &Path, progress: Option<&ProgressHandle>) -> Result<(), TranscribeError> { - let load_error = |source: std::io::Error| TranscribeError::LoadModel { - path: path.to_path_buf(), - source: Box::new(source), - }; - let total = std::fs::metadata(path).map_err(load_error)?.len(); - let mut file = std::fs::File::open(path).map_err(load_error)?; - let mut buffer = vec![0u8; PREWARM_CHUNK]; - let mut done = 0u64; - loop { - let read = file.read(&mut buffer).map_err(load_error)?; - if read == 0 { - break; - } - done += read as u64; - if let Some(leaf) = progress { - leaf.set_units(done, total); - } - } - if let Some(leaf) = progress { - leaf.complete(); - } - Ok(()) -} - -/// Runs one blocking whisper pass over `samples` and concatenates the -/// segments. `prompt`, when non-empty after sanitizing, conditions the -/// decoder on the take's transcript so far; `single_segment` forces the -/// whole buffer into one decoding pass (the interim sliding-window case). -pub(super) fn transcribe_blocking( - state: &mut WhisperState, - samples: &[f32], - prompt: Option<&str>, - single_segment: bool, -) -> Result { - let mut params = FullParams::new(SamplingStrategy::Greedy { best_of: 1 }); - params - .set_language(Some("en")) - .map_err(|source| TranscribeError::Inference(Box::new(source)))?; - params.set_translate(false); - // Decoder state never carries across passes: conditioning travels only - // through the explicit prompt, or a hallucination would compound. - params.set_no_context(true); - params.set_single_segment(single_segment); - params.set_no_timestamps(true); - params.set_print_special(false); - params.set_print_progress(false); - params.set_print_realtime(false); - params.set_print_timestamps(false); - params.set_suppress_blank(true); - params.set_suppress_nst(true); - if let Some(prompt) = prompt { - let prompt = sanitize_prompt(prompt); - if !prompt.is_empty() { - params - .set_initial_prompt(&prompt) - .map_err(|source| TranscribeError::Inference(Box::new(source)))?; - } - } - state - .full(¶ms, samples) - .map_err(|source| TranscribeError::Inference(Box::new(source)))?; - let mut text = String::new(); - for segment in 0..state.segment_count() { - let piece = state - .segment_text(segment) - .map_err(|source| TranscribeError::Inference(Box::new(source)))?; - text.push_str(&piece); - } - Ok(text.trim().to_string()) -} - -#[cfg(test)] -mod tests { - // Fractions are fixed-point millionths, so equality comparisons are exact. - #![expect(clippy::float_cmp, reason = "fixed-point fractions compare exactly")] - - use std::sync::Arc; - - use shared_progress::ProgressHub; - - use super::*; - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn prewarm_drives_the_leaf_to_completion() { - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let leaf = tree.register("prewarm", 1.0); - prewarm(&crate::fixtures::require_model(), Some(&leaf)) - .expect("prewarm reads the fixture model"); - assert_eq!( - leaf.fraction(), - 1.0, - "reading the whole file completes the leaf" - ); - } - - #[test] - fn prewarm_of_a_plain_file_drives_the_leaf_to_completion() { - let dir = tempfile::tempdir().expect("temp dir for the prewarm test"); - let path = dir.path().join("model.bin"); - std::fs::write(&path, vec![0u8; 1024]).expect("write the fake model"); - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let leaf = tree.register("prewarm", 1.0); - prewarm(&path, Some(&leaf)).expect("prewarm reads the file"); - assert_eq!( - leaf.fraction(), - 1.0, - "reading the whole file completes the leaf" - ); - } - - #[test] - fn prewarm_of_a_missing_file_fails_as_load_model_naming_the_path() { - let hub = Arc::new(ProgressHub::new()); - let tree = hub.operation(); - let leaf = tree.register("prewarm", 1.0); - let err = prewarm( - Path::new("definitely-missing-prewarm-model.bin"), - Some(&leaf), - ) - .expect_err("a missing file must fail"); - assert!( - matches!(err, TranscribeError::LoadModel { .. }), - "expected LoadModel, got {err:?}" - ); - assert!( - err.to_string() - .contains("definitely-missing-prewarm-model.bin"), - "error names the path: {err}" - ); - } -} diff --git a/crates/gateway-whisper-ffi/module-ceilings.toml b/crates/gateway-whisper-ffi/module-ceilings.toml new file mode 100644 index 00000000..d7db5027 --- /dev/null +++ b/crates/gateway-whisper-ffi/module-ceilings.toml @@ -0,0 +1,14 @@ +# Exact source and public-root counts for the Whisper FFI leaf. +# Physical lines include comments and blanks. Every recorded ceiling equals +# the measured file size, so any size change updates this manifest explicitly. + +public_root_count = 6 + +[modules] +"context.rs" = 226 +"error.rs" = 114 +"lib.rs" = 80 +"library.rs" = 204 +"log.rs" = 116 +"params.rs" = 189 +"raw.rs" = 151 diff --git a/crates/gateway-whisper-ffi/src/lib.rs b/crates/gateway-whisper-ffi/src/lib.rs index 056ec92d..e4d29ff7 100644 --- a/crates/gateway-whisper-ffi/src/lib.rs +++ b/crates/gateway-whisper-ffi/src/lib.rs @@ -21,6 +21,7 @@ pub use error::WhisperError; pub use library::WhisperLibrary; pub use params::{FullParams, SamplingStrategy}; +// Miri excludes dynamic library loading and native log callback tests; native CI owns them. #[cfg(test)] mod tests { use std::path::PathBuf; diff --git a/crates/gateway/AGENTS.md b/crates/gateway/AGENTS.md index 82c213a6..3c569611 100644 --- a/crates/gateway/AGENTS.md +++ b/crates/gateway/AGENTS.md @@ -6,4 +6,4 @@ This crate owns the inference gateway: OpenAI-shaped HTTP routing, profile switc - The CUDA `llama-server` is a managed download produced by the `build-llama-cuda` release workflow, never a Cargo build product. - The `web-search` feature is additive and defaults on; it gates the `gateway-web-search` dependency and the `POST /v1/tools/web_search` route. The gateway keeps auth and the mount/reload shim; the service crate never sees `GatewayError`. - Gateway-hosted speech-to-text lifecycle and HTTP routes live in `gateway-stt` behind the default-on `stt` feature; a `--no-default-features` build stubs the route and refuses `[[stt_model]]` configurations. -- The gateway never hosts or embeds the workshop: the desktop shell spawns `workshop-server` in-process and attaches over HTTP, and the `gateway` crate has no `workshop` feature and no `workshop-server` dependency (the `gateway-stt` crate keeps its own `workshop-server` edge for the `/stt` socket attach API until voice migrates into workshop-server). A boot config carrying a `[workshop]` section must keep parsing - startup logs a deprecation warning naming the inert `bind`/`open_browser` fields and the still-live `[workshop.stt]` capture tuning; never fail or silently ignore it. +- The gateway never hosts or embeds the workshop: the desktop shell spawns `workshop-server` in-process and attaches over HTTP, and no Gateway crate depends on `workshop-server`. A boot config carrying a `[workshop]` section must keep parsing - startup logs a deprecation warning naming the inert `bind`/`open_browser` fields; never fail or silently ignore it. diff --git a/crates/gateway/Cargo.toml b/crates/gateway/Cargo.toml index 0d6a406f..1478643c 100644 --- a/crates/gateway/Cargo.toml +++ b/crates/gateway/Cargo.toml @@ -39,6 +39,9 @@ gateway-config-ui = { workspace = true, optional = true } # Optional: gateway-owned local inference (GGUF provisioning, managed # `llama-server` children, blob cache store). Headless builds disable it. gateway-local = { workspace = true, optional = true } +# The bounded log pipeline: rotation, the priority queue, and the worker +# thread behind the file layer's MakeWriter. +gateway-logging.workspace = true # The first-run bearer key (src/boot.rs) comes from the OS-seeded CSPRNG. rand.workspace = true # The shared loopback wall for the admin config endpoints; always on, @@ -131,11 +134,16 @@ config-ui = ["dep:gateway-config-ui"] stt = ["dep:gateway-stt"] [dev-dependencies] +base64.workspace = true +hound.workspace = true # Encodes the generated test image for the live CUDA projector proof. png.workspace = true # test-util pauses time so the progress heartbeat test runs instantly. tokio = { workspace = true, features = ["test-util"] } +tokio-tungstenite.workspace = true gateway-routing = { workspace = true, features = ["test-helpers"] } +gateway-stt = { workspace = true, features = ["test-fixtures"] } +gateway-stt-engine = { workspace = true, features = ["test-fixtures"] } tempfile.workspace = true # Drives build_router in-process with forged peer addresses, so the # loopback-wall tests can present a LAN peer no real TCP connection could. diff --git a/crates/gateway/README.md b/crates/gateway/README.md index 4fdd3297..743c3bf8 100644 --- a/crates/gateway/README.md +++ b/crates/gateway/README.md @@ -15,18 +15,20 @@ cargo install gateway ## Usage ```bash -promptforge-gateway serve gateway.toml --profile main +promptforge-gateway --config gateway.toml --profile main ``` -The config path comes from the positional argument or the `PROMPTFORGE_GATEWAY_CONFIG` environment variable (the CLI argument wins). With neither set, the gateway searches beside the executable, then the working directory, then the user profile's `.promptforge` directory; when no `gateway.toml` exists, first run writes a default there - loopback on an OS-assigned port, a fresh random bearer key, `trust_loopback = true` so same-machine callers need no key (with the shared-machine caveat and the `trust_loopback = false` opt-out noted in the file), the recommended STT pair unless the installer declined it - and boots from it. The profile comes from `--profile NAME`, the `PROMPTFORGE_PROFILE` environment variable, or the sibling state file, in that precedence; with none set, startup refuses and lists the profiles the config defines. The generated default writes its state file selecting `default`, so a bare first boot needs no flags. +The config path comes from the `--config` flag or the `PROMPTFORGE_GATEWAY_CONFIG` environment variable (the flag wins). With neither set, the gateway searches beside the executable, then the working directory, then the user profile's `.promptforge` directory; when no `gateway.toml` exists, first run writes a default there - loopback on an OS-assigned port, a fresh random bearer key, `trust_loopback = true` so same-machine callers need no key (with the shared-machine caveat and the `trust_loopback = false` opt-out noted in the file), the recommended STT pair unless the installer declined it - and boots from it. The profile comes from `--profile NAME`, the `PROMPTFORGE_PROFILE` environment variable, or the sibling state file, in that precedence; with none set, startup refuses and lists the profiles the config defines. The generated default writes its state file selecting `default`, so a bare first boot needs no flags. -Configure endpoints, models, and credentials in the TOML catalog. The gateway accepts `POST /v1/chat/completions`, serves a model catalog at `GET /v1/models`, and, with the default-on `stt` feature, serves streaming dictation at `/stt`, capability discovery at `GET /stt/capability`, and OpenAI-compatible multipart transcription at `POST /v1/audio/transcriptions`. +A serving run logs to `gateway.log` in the `logs` directory under the state directory, rotating the previous run aside on startup and retaining five previous runs; every record crosses a redaction pass that masks bearer tokens, authorization and cookie header values, and `api_key` assignments before it reaches disk. When a run fails before it can serve, `promptforge-gateway diagnostics` prints a read-only JSON report of the state directory, the resolved config path, the current and retained log files, and the connection file - it never serves, rotates a log, parses a config, or prints secrets. + +Configure endpoints, models, and credentials in the TOML catalog. The gateway accepts `POST /v1/chat/completions`, serves a model catalog at `GET /v1/models`, and, with the default-on `stt` feature, serves Realtime transcription at `WS /v1/realtime?intent=transcription` plus OpenAI-compatible multipart transcription at `POST /v1/audio/transcriptions`. Embedding hosts use the library API instead of the binary: `spawn` starts the gateway on a dedicated thread with its own runtime and blocks until the listener is bound, returning a `GatewayHandle` that carries the bound URL and a graceful-shutdown switch (`url()`, `shutdown()`, `join()`). ## System tray -On Windows the binary's default main loop is the system tray: a hidden-window win32 message loop owns the main thread while serving stays on the gateway thread. The menu carries a disabled status line on top (gateway state plus served models and declared VRAM, refreshed on a timer from in-process state), then **Workshop** (launches `promptforge-workshop.exe` when the installer laid it beside the gateway; disabled on a Gateway-only install), **Settings** (opens the config SPA in the browser through the one-time `/auth?key=` handoff, as does double-clicking the icon), **Launch at Login** (a check item whose state is the HKCU Run key entry `PromptForgeGateway`, never local config), and **Quit** last, which fires the in-process shutdown signal directly. `--no-tray` keeps the headless Ctrl-C loop for servers and CI; the autostart entry's `"" serve --login` command line marks login launches, which never open a browser. `--browser` opens the Settings page in the default browser once the listener is bound - the installer's first run uses it. On macOS the NSApplication run loop owns the main thread, the icon is a template glyph, and Launch at Login registers through `SMAppService` when the gateway is its bundle's principal executable. On Linux the tray is a pure StatusNotifierItem over the session D-Bus (ksni; no GTK, no libappindicator): icon clicks carry no events there, so the menu is the only path, and Launch at Login writes `~/.config/autostart/promptforge-gateway.desktop` (a user-deleted entry is never resurrected). A desktop with no StatusNotifierWatcher - stock GNOME without the AppIndicator extension - keeps serving trayless, posts one first-run notification naming the Settings URL, and registers the tray automatically when a watcher appears. Two CLI affordances serve tray-less environments: `--print-url` prints the Settings handoff URL to stdout once bound and then serves headless, and a second `promptforge-gateway` launch while one is running never boots a duplicate - it hands off before any bind attempt, opening the running gateway's Settings page (printing its URL under `--print-url`, exiting quietly under `--login`). Platforms without a backend fall back to the headless loop with a warning. +On Windows the binary's default main loop is the system tray: a hidden-window win32 message loop owns the main thread while serving stays on the gateway thread. The menu carries a disabled status line on top (gateway state plus served models and declared VRAM, refreshed on a timer from in-process state), then **Workshop** (launches `promptforge-workshop.exe` when the installer laid it beside the gateway; disabled on a Gateway-only install), **Settings** (opens the config SPA in the browser through the one-time `/auth?key=` handoff, as does double-clicking the icon), **Launch at Login** (a check item whose state is the HKCU Run key entry `PromptForgeGateway`, never local config), and **Quit** last, which fires the in-process shutdown signal directly. `--no-tray` keeps the headless Ctrl-C loop for servers and CI; the autostart entry's `"" --login` command line marks login launches, which never open a browser. `--browser` opens the Settings page in the default browser once the listener is bound - the installer's first run uses it. On macOS the NSApplication run loop owns the main thread, the icon is a template glyph, and Launch at Login registers through `SMAppService` when the gateway is its bundle's principal executable. On Linux the tray is a pure StatusNotifierItem over the session D-Bus (ksni; no GTK, no libappindicator): icon clicks carry no events there, so the menu is the only path, and Launch at Login writes `~/.config/autostart/promptforge-gateway.desktop` (a user-deleted entry is never resurrected). A desktop with no StatusNotifierWatcher - stock GNOME without the AppIndicator extension - keeps serving trayless, posts one first-run notification naming the Settings URL, and registers the tray automatically when a watcher appears. Two CLI affordances serve tray-less environments: `--print-url` prints the Settings handoff URL to stdout once bound and then serves headless, and a second `promptforge-gateway` launch while one is running never boots a duplicate - it hands off before any bind attempt, opening the running gateway's Settings page (printing its URL under `--print-url`, exiting quietly under `--login`). Platforms without a backend fall back to the headless loop with a warning. See the [PromptForge User Guide](https://cppalliance.github.io/promptforge/) for full documentation. @@ -90,10 +92,10 @@ Four feature flags exist: - `local` (default) - compiles in gateway-owned local inference via the `gateway-local` crate: GGUF provisioning, managed `llama-server` children, the blob cache behind the `/v1/cache` routes, the `GET /admin/orphans` listing of cache files no loaded `[[local_model]]` entry references (sizes from the filesystem, digests only from cache sidecars - multi-gigabyte blobs are never re-hashed), the `GET /admin/model-info?path=` GGUF-header readout of a cache file's architecture, layer count, and parameter count (the `path` must stay inside the artifact cache; only the header is read, never tensor data), and the bearer-authenticated `GET /admin/chat-templates` catalog used by the Config UI. A `--no-default-features` build is headless of local inference: it links neither the archive/extraction stack nor a blocking HTTP client, and it refuses a configuration declaring `[[local_model]]` at startup and on profile switch. - `web-search` (default) - compiles in the Brave-powered `POST /v1/tools/web_search` tool service via the `gateway-web-search` crate. A `--no-default-features` build omits the route entirely. -- `stt` (default) - compiles in gateway-owned speech-to-text via the `gateway-stt` crate: the transcription engine lifecycle, streaming `/stt` routes, and `POST /v1/audio/transcriptions` on the gateway listener. A `--no-default-features` build omits the routes and refuses a configuration declaring `[[stt_model]]` at startup and on profile switch. +- `stt` (default) - compiles in gateway-owned speech-to-text via the `gateway-stt` facade: artifact preparation, atomic generation replacement, `WS /v1/realtime?intent=transcription`, and `POST /v1/audio/transcriptions` on the gateway listener. A `--no-default-features` build omits the routes and speech status and refuses a configuration declaring `[[stt_model]]` at startup and on profile switch. - `config-ui` (default) - compiles in the embedded config SPA via the `gateway-config-ui` crate and serves it at `/config/` on the gateway's own port (no second listener); `GET /config` redirects to `/config/`. The routes are loopback-only and carry no bearer auth (the SPA shell holds no secrets); Node/esbuild and `rust-embed` enter the build only with this feature: Node 22 is needed on the build machine for the UI bundle's esbuild step, not for Rust itself, and a `--no-default-features` build needs no Node at all. With the feature, `GET /auth?key=` is the browser handoff onto the surface: it validates the bearer key, sets a session proof derived from it (SHA-256 over a process-lifetime salt and the key, so the cookie never carries the key and a restart or key rotation revokes it) as an HttpOnly `SameSite=Lax` session cookie, and 302-redirects to the key-free `/config/`, which accepts the cookie in place of the `Authorization` header - a tray or shell can open the UI without leaving the key in browser history. Because the cookie is ambient, the cookie path also requires `Sec-Fetch-Site: same-origin` or `none` fetch metadata, which browsers attach and a cross-origin page cannot strip. Regardless of the feature, the admin config endpoints (config read/write, env, pending state, apply/revert, orphans, system, model-info, chat templates, the HF proxy, profile create/delete, reveal) plus `POST /shutdown` and `GET /auth` sit behind the shared loopback wall from the always-on `shared-loopback` crate: a non-loopback peer gets 403 before bearer auth even runs. `POST /shutdown` is the bearer-authed graceful stop - the same drain Ctrl-C drives - answering 202 before the server goes down; the tray's Quit and the shell's Quit-everything call it. And whenever the listener is bound to a loopback address, every route sits behind the wall's second middleware, a host-authority allowlist that refuses with 403 any request whose `Host` is not the bound socket (`127.0.0.1:port`, `[::1]:port`, or `localhost:port`), closing DNS rebinding; a non-loopback bind enforces no allowlist. -The speech runtime itself is a pinned managed download selected for the host at run time. Note the build graph: the default-on `stt` feature's `gateway-stt` crate depends on `workshop-server` (the `/stt` socket attach API), whose build script bundles the workshop UI with esbuild - so default builds need Node 22 even though the gateway serves no workshop pages, and only a `--no-default-features` build drops that requirement. The gateway hosts no workshop UI: the desktop shell embeds the workshop server itself, and a boot config carrying a `[workshop]` section still parses but earns a deprecation warning at startup - its `bind` and `open_browser` settings are inert, while `[workshop.stt]` capture tuning still applies to the STT engine. +The speech runtime itself is a pinned managed download selected for the host at run time. The Gateway STT stack has no Workshop dependency: `gateway-stt` owns the generic routes and lifecycle, `gateway-stt-engine` owns backend-neutral workers, `gateway-stt-backend-whisper` owns safe Whisper policy, and `gateway-whisper-ffi` is the unsafe ABI leaf. A Gateway build never invokes Workshop UI tooling. The gateway hosts no workshop UI: the desktop shell embeds the workshop server itself, and a boot config carrying a `[workshop]` section still parses but earns a deprecation warning at startup because its `bind` and `open_browser` settings are inert. ### Speech-to-text models @@ -119,16 +121,26 @@ vram_gb = 1.0 A profile may select at most one interim and one final STT model. Interim without final is allowed as a degraded mode: nothing crystallizes mid-take and the final pass falls back to one interim decode at stop. Final without interim is a validation error naming the fix. The config crate ships a digest-pinned recommended pair - `whisper-base-en` (interim) and `whisper-small-en` (final) from the whisper.cpp Hugging Face repo - and the Config UI's **Restore recommended models** button writes both entries into the pending config. -`[workshop.stt]` (optional) configures push-to-talk capture tuning. Model +`[stt]` (optional) configures speech pipeline tuning. Model sources, pins, and interim/final roles live in the global `[[stt_model]]` entries above; the active profile enables them by catalog name. +Version 2 accepts only the canonical `[stt]` section. Legacy `[workshop.stt]` input is rejected as an unknown workshop field whether it appears alone or beside `[stt]`, and config serialization writes only `[stt]`. + | Field | Default | Meaning | |---|---|---| | `window_seconds` | `15` | Seconds of trailing audio each interim pass transcribes. | | `interval_ms` | `500` | Milliseconds between interim passes while a take is recording. | | `vocabulary` | `[]` | Domain terms whisper is biased toward. Empty disables biasing. | +### Realtime transcription + +The authenticated Realtime endpoint accepts only the exact `intent=transcription` query. A session uses signed little-endian mono PCM16 at 24 kHz, the logical model `realtime-transcribe`, null noise reduction and turn detection, and `session.update`, `input_audio_buffer.append`, `input_audio_buffer.clear`, and `input_audio_buffer.commit` client events. The server resamples continuously to 16 kHz for the backend and returns OpenAI-shaped session, item, delta, completion, failure, and error events. + +Clients may negotiate the PromptForge extension `item.input_audio_transcription.hypothesis`. Its replacement snapshots carry the complete transcript plus finalized, agreed, and tentative regions until the authoritative completion arrives. At most eight Realtime sessions are active at once and each session may have at most four committed items finalizing concurrently; bounded overloads fail visibly rather than waiting without limit. + +`GET /v1/models` advertises active physical speech names for batch calls and advertises `realtime-transcribe` only when the complete interim and final pair is ready. `GET /admin/status` reports generic `speech` facts: `configured`, `ready`, `gpu`, and `generation`. + ## Local model companions A chat `[[local_model]]` can declare two companions, each provisioned through the same pinned, digest-verified cache machinery as the main model: diff --git a/crates/gateway/build.rs b/crates/gateway/build.rs index c46f6858..beddea67 100644 --- a/crates/gateway/build.rs +++ b/crates/gateway/build.rs @@ -26,6 +26,7 @@ const ICON: &str = "../workshop/icons/icon.ico"; /// `muda`'s `common-controls-v6` feature requires. The resource script /// references it as `CREATEPROCESS_MANIFEST_RESOURCE_ID` (1) of type /// `RT_MANIFEST` (24). +#[cfg(windows)] const MANIFEST: &str = r#" diff --git a/crates/gateway/packaging/gateway.service b/crates/gateway/packaging/gateway.service index 145b2774..1938e474 100644 --- a/crates/gateway/packaging/gateway.service +++ b/crates/gateway/packaging/gateway.service @@ -5,7 +5,7 @@ Wants=network-online.target [Service] Type=simple -ExecStart=/usr/local/bin/promptforge-gateway serve /etc/promptforge/gateway.toml --profile main +ExecStart=/usr/local/bin/promptforge-gateway --config /etc/promptforge/gateway.toml --profile main Restart=on-failure RestartSec=5 # The gateway holds vendor credentials; run it as a dedicated user. diff --git a/crates/gateway/src/boot.rs b/crates/gateway/src/boot.rs index b02f810a..6bdff28e 100644 --- a/crates/gateway/src/boot.rs +++ b/crates/gateway/src/boot.rs @@ -1,13 +1,14 @@ //! Boot-time configuration: discovery and first-run provisioning. //! -//! An explicit config path (the CLI positional or `PROMPTFORGE_GATEWAY_CONFIG`, -//! resolved by the binary) always wins. Without one, the discovery search -//! looks beside the executable, then in the working directory, then in the -//! user profile's `.promptforge` directory. When no location holds a -//! `gateway.toml`, first-run generation writes the sidecar-shaped default - -//! loopback on an OS-assigned port, a fresh random bearer key, the -//! recommended STT pair unless the installer declined it - into the profile -//! location, and the boot proceeds from it. +//! An explicit config path (the CLI `--config` flag or +//! `PROMPTFORGE_GATEWAY_CONFIG`, resolved by the binary) always wins. +//! Without one, the discovery search looks beside the executable, then in +//! the working directory, then in the user profile's `.promptforge` +//! directory. When no location holds a `gateway.toml`, first-run +//! generation writes the sidecar-shaped default - loopback on an +//! OS-assigned port, a fresh random bearer key, the recommended STT pair +//! unless the installer declined it - into the profile location, and the +//! boot proceeds from it. use std::path::{Path, PathBuf}; @@ -368,6 +369,36 @@ fn resolve_in( Ok(path) } +/// The config path a diagnostics report names: the explicit path when +/// given, else the first discovery candidate that exists, else the +/// profile location first-run generation would write. Reads only - it +/// never generates. `None` when no location can be determined at all. +pub(crate) fn discover_for_report(explicit: Option) -> Option { + discover_in(explicit, Locations::gather) +} + +/// The testable discovery chain: like [`resolve_in`], `gather` runs only +/// when `explicit` is `None`, so an explicit-path report never depends on +/// location lookups. Unlike `resolve_in` this never generates: the +/// profile location is named, not written. +fn discover_in( + explicit: Option, + gather: impl FnOnce() -> Result, +) -> Option { + if explicit.is_some() { + return explicit; + } + let locations = gather().ok()?; + Some( + first_existing(&candidates_from( + &locations.exe_dir, + &locations.cwd, + &locations.home, + )) + .unwrap_or_else(|| profile_config_path(&locations.home)), + ) +} + /// The profile candidate: `/.promptforge/gateway.toml`. This is the /// one place that knows where the profile configuration lives, so /// first-run generation writes where discovery reads. @@ -497,6 +528,7 @@ fn default_boot_config(api_key: &str, stt: InstallerStt) -> String { # PromptForge gateway configuration # Generated on first run. Edit as needed. # See: crates/gateway/README.md +# Diagnostics: promptforge-gateway diagnostics [server] bind = "127.0.0.1:0" @@ -622,6 +654,47 @@ mod tests { ); } + #[test] + fn the_report_discovery_returns_an_explicit_path_without_a_lookup() { + let discovered = discover_in(Some(PathBuf::from("explicit/gateway.toml")), || { + panic!("an explicit path skips the location lookup") + }); + assert_eq!(discovered, Some(PathBuf::from("explicit/gateway.toml"))); + } + + #[test] + fn the_report_discovery_names_an_existing_candidate() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let dirs = locations(&temp); + std::fs::create_dir_all(&dirs.cwd).expect("create cwd"); + let in_cwd = dirs.cwd.join(CONFIG_FILE_NAME); + std::fs::write(&in_cwd, "").expect("write fixture"); + + let discovered = discover_in(None, || Ok(locations(&temp))); + + assert_eq!(discovered, Some(in_cwd)); + } + + #[test] + fn the_report_discovery_falls_back_to_the_profile_without_generating() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let dirs = locations(&temp); + + let discovered = discover_in(None, || Ok(locations(&temp))); + + assert_eq!(discovered, Some(profile_config_path(&dirs.home))); + assert!( + !dirs.home.join(".promptforge").exists(), + "the report names the profile location but never writes it" + ); + } + + #[test] + fn the_report_discovery_reads_an_unlocatable_process_as_none() { + let discovered = discover_in(None, || Err(BootError::NoHome)); + assert_eq!(discovered, None); + } + #[test] fn first_run_generates_a_bootable_config_into_the_profile() { let temp = tempfile::TempDir::new().expect("tempdir"); @@ -691,6 +764,21 @@ mod tests { ); } + #[test] + fn the_generated_config_carries_the_diagnostics_hint_as_a_comment() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let path = generate_default(&temp.path().join(CONFIG_FILE_NAME), InstallerStt::Included) + .expect("generates"); + let raw = std::fs::read_to_string(&path).expect("read back"); + + assert!( + raw.contains("# Diagnostics: promptforge-gateway diagnostics\n"), + "the hint is a comment, never a config field: {raw}" + ); + gateway_config::Config::from_toml_str(&raw) + .expect("a commented hint leaves the config parseable"); + } + #[test] fn the_generated_config_omits_stt_when_the_installer_declined_it() { let temp = tempfile::TempDir::new().expect("tempdir"); diff --git a/crates/gateway/src/config_apply.rs b/crates/gateway/src/config_apply.rs index 62cfec28..0b5cd43e 100644 --- a/crates/gateway/src/config_apply.rs +++ b/crates/gateway/src/config_apply.rs @@ -330,6 +330,11 @@ pub(crate) async fn apply_config( // whose reply promises the shadows are still staged. #[cfg(feature = "local")] Err(error @ GatewayError::PartialStart { .. }) => Err(error), + // Fatal replacement outcomes deliberately fire both cancellation + // and controlled shutdown after persistence became indeterminate or + // native staging outlived its deadline. Preserve that failure instead + // of promising the shadows are still staged. + Err(error) if state.shutdown.is_fired() => Err(error), // Any other failure under a fired token reports as the cancellation // it is, however deep in the switch the stop landed. Err(_) if token.is_cancelled() => Err(GatewayError::CommandCancelled( @@ -672,6 +677,33 @@ models = ["beta-model"] ); } + #[tokio::test] + async fn stt_pipeline_change_reloads_without_restart() { + let (_temp, config, paths) = fixture(); + write_shadow( + &paths.config_path, + &format!( + "{CONFIG}\n[stt]\nwindow_seconds = 8\ninterval_ms = 250\n\ + vocabulary = [\"WG21\"]\n" + ), + ) + .expect("stage STT-only shadow"); + let (addr, _state) = serve_fixture(config, paths).await; + let dirty = get_json(addr, "admin/config-dirty").await; + assert_eq!(dirty["changed_sections"], serde_json::json!(["stt"])); + + let response = post(addr, "admin/config-apply").await; + + assert_eq!(response.status(), reqwest::StatusCode::OK); + let reply: serde_json::Value = response.json().await.expect("apply body"); + assert_eq!(reply["reloaded"], true); + assert_eq!(reply["restart_required"], false); + let applied = get_json(addr, "admin/config").await; + assert_eq!(applied["stt"]["window_seconds"], 8); + assert_eq!(applied["stt"]["interval_ms"], 250); + assert_eq!(applied["stt"]["vocabulary"], serde_json::json!(["WG21"])); + } + #[tokio::test] async fn revert_removes_all_shadows_without_touching_real_files() { let (_temp, config, paths) = fixture(); diff --git a/crates/gateway/src/config_pending.rs b/crates/gateway/src/config_pending.rs index 40df0c90..a210e61e 100644 --- a/crates/gateway/src/config_pending.rs +++ b/crates/gateway/src/config_pending.rs @@ -32,6 +32,7 @@ pub(crate) async fn admin_config_pending( caller: Caller, ) -> Result, GatewayError> { check_auth(&state, &caller).await?; + let _publication = state.apply.lock().await; let config_path = crate::config_path(&state)?.to_path_buf(); let running_profile = state.live.read().await.profile_name.clone(); let reply = tokio::task::spawn_blocking(move || { @@ -85,6 +86,7 @@ pub(crate) async fn admin_config_dirty( caller: Caller, ) -> Result, GatewayError> { check_auth(&state, &caller).await?; + let _publication = state.apply.lock().await; let config_path = crate::config_path(&state)?.to_path_buf(); let reply = tokio::task::spawn_blocking(move || dirty_reply(&config_path)) .await diff --git a/crates/gateway/src/diagnostics.rs b/crates/gateway/src/diagnostics.rs new file mode 100644 index 00000000..93630f89 --- /dev/null +++ b/crates/gateway/src/diagnostics.rs @@ -0,0 +1,276 @@ +//! The `diagnostics` subcommand's report: formatted JSON naming the state +//! directory, the config path, the log files, and the connection file, +//! plus whether a gateway is running right now. +//! +//! The report is read-only by contract: it never initializes logging, +//! never rotates a log, never parses configuration, and never mutates the +//! state directory - a stale connection file reads as not-running and +//! stays on disk for the next launch to clean. It never carries the +//! bearer key, environment values, config contents, or log contents. + +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; + +/// Builds the diagnostics report as formatted JSON. +/// +/// `explicit_config` is the CLI- or environment-resolved config path when +/// one was given; without it the report names the path boot discovery +/// would use (the profile location when nothing exists yet). +#[must_use] +pub fn diagnostics_json(explicit_config: Option) -> String { + let run_dir = shared_sidecar::default_run_dir(); + let state_dir = run_dir + .as_deref() + .and_then(Path::parent) + .map(Path::to_path_buf); + let config_path = crate::boot::discover_for_report(explicit_config); + let running = run_dir.as_deref().is_some_and(shared_sidecar::is_running); + render( + state_dir.as_deref(), + config_path.as_deref(), + run_dir.as_deref(), + running, + ) +} + +/// A JSON string literal for `text`, with every escape handled. +fn json_string(text: &str) -> String { + serde_json::to_string(text).unwrap_or_else(|_| unreachable!("serializing a string cannot fail")) +} + +/// A path rendered as a JSON string, or `null` when the location could +/// not be determined at all. +fn json_path(path: Option<&Path>) -> String { + path.map_or_else( + || "null".to_string(), + |path| json_string(&path.to_string_lossy()), + ) +} + +/// One `{ "path": ..., "exists": ... }` entry. +fn path_entry(path: Option<&Path>) -> String { + format!( + "{{ \"path\": {}, \"exists\": {} }}", + json_path(path), + path.is_some_and(Path::is_file) + ) +} + +/// Renders the report in the contract's shape and key order. Pure apart +/// from the `exists` stat calls, so tests drive it with fixture +/// directories. +fn render( + state_dir: Option<&Path>, + config_path: Option<&Path>, + run_dir: Option<&Path>, + running: bool, +) -> String { + let connection_file = run_dir.map(shared_sidecar::connection_file_path); + let mut out = String::new(); + // Writing to a String is infallible, so each writeln's Result is + // dropped on purpose. + let _ = writeln!(out, "{{"); + let _ = writeln!(out, " \"state_dir\": {},", json_path(state_dir)); + let _ = writeln!(out, " \"config\": {},", path_entry(config_path)); + let _ = writeln!(out, " \"logs\": {{"); + let current = state_dir.map(|dir| gateway_logging::LogConfig::new(dir).log_path()); + let _ = writeln!(out, " \"current\": {},", path_entry(current.as_deref())); + if let Some(state_dir) = state_dir { + let retained = gateway_logging::LogConfig::new(state_dir).retained_log_paths(); + let _ = writeln!(out, " \"retained\": ["); + for (index, path) in retained.iter().enumerate() { + let comma = if index + 1 == retained.len() { "" } else { "," }; + let _ = writeln!(out, " {}{comma}", path_entry(Some(path))); + } + let _ = writeln!(out, " ]"); + } else { + let _ = writeln!(out, " \"retained\": []"); + } + let _ = writeln!(out, " }},"); + let _ = writeln!( + out, + " \"connection_file\": {},", + path_entry(connection_file.as_deref()) + ); + let _ = writeln!(out, " \"running\": {running},"); + let _ = writeln!( + out, + " \"version\": {}", + json_string(env!("CARGO_PKG_VERSION")) + ); + let _ = writeln!(out, "}}"); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Reads the report back as JSON for shape assertions. + fn parse(rendered: &str) -> serde_json::Value { + serde_json::from_str(rendered).expect("the report is valid JSON") + } + + #[test] + fn the_report_matches_the_contract_shape() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let state_dir = temp.path().join("state"); + let run_dir = state_dir.join("run"); + std::fs::create_dir_all(state_dir.join("logs")).expect("logs dir"); + std::fs::create_dir_all(&run_dir).expect("run dir"); + std::fs::write(state_dir.join("logs/gateway.log"), "current").expect("seed log"); + std::fs::write(state_dir.join("logs/gateway.log.1"), "previous").expect("seed rotation"); + let config = state_dir.join("gateway.toml"); + std::fs::write(&config, "config-version = 2\n").expect("seed config"); + + let rendered = render(Some(&state_dir), Some(&config), Some(&run_dir), false); + let report = parse(&rendered); + + // The exact key set, in the contract's order: serde_json sorts + // parsed objects, so the order assertion runs on the raw text. + let mut at = 0; + for key in [ + "\"state_dir\"", + "\"config\"", + "\"logs\"", + "\"current\"", + "\"retained\"", + "\"connection_file\"", + "\"running\"", + "\"version\"", + ] { + let found = rendered[at..] + .find(key) + .unwrap_or_else(|| panic!("{key} appears after position {at}: {rendered}")); + at += found + key.len(); + } + assert_eq!( + report["state_dir"].as_str().expect("a string"), + state_dir.to_string_lossy() + ); + assert_eq!( + report["config"]["path"].as_str(), + Some(&*config.to_string_lossy()) + ); + assert_eq!(report["config"]["exists"], true); + assert!( + report["logs"]["current"]["path"] + .as_str() + .expect("a string") + .ends_with("gateway.log") + ); + assert_eq!(report["logs"]["current"]["exists"], true); + let retained = report["logs"]["retained"].as_array().expect("an array"); + assert_eq!( + retained.len(), + 5, + "five retained slots, one per kept previous run" + ); + assert_eq!(retained[0]["exists"], true, "the seeded .1 exists"); + assert_eq!(retained[4]["exists"], false, ".5 was never written"); + assert!( + retained[0]["path"] + .as_str() + .expect("a string") + .ends_with("gateway.log.1") + ); + assert!( + report["connection_file"]["path"] + .as_str() + .expect("a string") + .ends_with("gateway.json") + ); + assert_eq!(report["connection_file"]["exists"], false); + assert_eq!(report["running"], false); + assert_eq!(report["version"].as_str(), Some(env!("CARGO_PKG_VERSION"))); + } + + #[test] + fn the_report_carries_no_secret_material() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let state_dir = temp.path().join("state"); + let run_dir = state_dir.join("run"); + std::fs::create_dir_all(&run_dir).expect("run dir"); + // A live-looking connection file with a bearer key: the report + // names the file but never reads its contents into the output. + shared_sidecar::ConnectionFile { + port: 8081, + api_key: "the-bearer-key".to_owned(), + pid: 4242, + epoch: 1_757_000_000, + version: "0.2.0".to_owned(), + started_at: "2026-09-05T12:00:00Z".to_owned(), + } + .write_to(&run_dir) + .expect("write the connection file"); + + let rendered = render(Some(&state_dir), None, Some(&run_dir), false); + assert!( + !rendered.contains("the-bearer-key"), + "the report never carries the bearer key: {rendered}" + ); + assert_eq!(parse(&rendered)["connection_file"]["exists"], true); + } + + #[test] + fn the_report_mutates_nothing() { + let temp = tempfile::TempDir::new().expect("tempdir"); + let state_dir = temp.path().join("state"); + let run_dir = state_dir.join("run"); + std::fs::create_dir_all(state_dir.join("logs")).expect("logs dir"); + std::fs::create_dir_all(&run_dir).expect("run dir"); + std::fs::write(state_dir.join("logs/gateway.log"), "the running log").expect("seed log"); + std::fs::write(run_dir.join("gateway.json"), b"not json").expect("a stale file"); + + let before = std::fs::read_to_string(state_dir.join("logs/gateway.log")).expect("read"); + render(Some(&state_dir), None, Some(&run_dir), false); + + assert_eq!( + std::fs::read_to_string(state_dir.join("logs/gateway.log")).expect("read"), + before, + "the current log is untouched" + ); + assert!( + !state_dir.join("logs/gateway.log.1").exists(), + "no rotation happened" + ); + assert!( + run_dir.join("gateway.json").exists(), + "a stale connection file is left for the next launch to clean" + ); + } + + #[test] + fn an_unlocatable_state_dir_renders_null_paths() { + let report = parse(&render(None, None, None, false)); + assert!(report["state_dir"].is_null()); + assert!(report["config"]["path"].is_null()); + assert_eq!(report["config"]["exists"], false); + assert!(report["logs"]["current"]["path"].is_null()); + assert_eq!( + report["logs"]["retained"] + .as_array() + .expect("an array") + .len(), + 0, + "no state dir, no retained list" + ); + assert!(report["connection_file"]["path"].is_null()); + assert_eq!(report["running"], false); + } + + #[test] + fn windows_path_separators_survive_json_escaping() { + let report = parse(&render( + Some(Path::new("C:\\Users\\v\\.promptforge")), + None, + None, + false, + )); + assert_eq!( + report["state_dir"].as_str(), + Some("C:\\Users\\v\\.promptforge"), + "backslashes round-trip through the JSON escaping" + ); + } +} diff --git a/crates/gateway/src/error.rs b/crates/gateway/src/error.rs index 5caea78b..ca91d875 100644 --- a/crates/gateway/src/error.rs +++ b/crates/gateway/src/error.rs @@ -43,17 +43,6 @@ pub(crate) enum GatewayError { #[error("malformed request: {0}")] MalformedRequest(String), - /// The uploaded transcription body exceeded the configured cap. - #[cfg(feature = "stt")] - #[error("audio file exceeds the 25 MiB limit")] - AudioTooLarge, - - /// The active STT engine rejected an otherwise valid request. - #[cfg(feature = "stt")] - #[non_exhaustive] - #[error("transcription failed")] - Transcription(#[source] gateway_stt::TranscriptionError), - /// 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. @@ -237,23 +226,6 @@ impl From for GatewayError { } } -#[cfg(feature = "stt")] -impl From for GatewayError { - fn from(value: gateway_stt::TranscriptionError) -> Self { - if let Some(model) = value.model_not_found() { - return GatewayError::UnknownModel(model.to_owned()); - } - if value.is_file_too_large() { - return GatewayError::AudioTooLarge; - } - if value.is_inference() { - GatewayError::Transcription(value) - } else { - GatewayError::MalformedRequest(value.to_string()) - } - } -} - #[cfg(feature = "web-search")] impl From for GatewayError { fn from(value: gateway_web_search::WebSearchError) -> Self { @@ -346,18 +318,6 @@ impl GatewayError { "invalid_request_error", "malformed_request", ), - #[cfg(feature = "stt")] - GatewayError::AudioTooLarge => ( - StatusCode::PAYLOAD_TOO_LARGE, - "invalid_request_error", - "file_too_large", - ), - #[cfg(feature = "stt")] - GatewayError::Transcription(_) => ( - StatusCode::INTERNAL_SERVER_ERROR, - "server_error", - "transcription_error", - ), GatewayError::Protocol(error) => error.classify(), GatewayError::QueueFull => ( StatusCode::SERVICE_UNAVAILABLE, @@ -749,24 +709,6 @@ mod tests { ); } - #[cfg(feature = "stt")] - #[test] - fn unloaded_stt_model_maps_to_openai_model_not_found() { - let error = GatewayError::from(gateway_stt::TranscriptionError::model_not_found_error( - "ghost", - )); - assert!(matches!(error, GatewayError::UnknownModel(model) if model == "ghost")); - let error = GatewayError::UnknownModel("ghost".to_owned()); - assert_eq!( - error.classify(), - ( - StatusCode::NOT_FOUND, - "invalid_request_error", - "model_not_found" - ) - ); - } - #[test] fn switch_failed_preserves_its_cause() { let error = GatewayError::switch_failed("load-profile", std::io::Error::other("disk")); diff --git a/crates/gateway/src/lib.rs b/crates/gateway/src/lib.rs index 82b382a0..a1603fdf 100644 --- a/crates/gateway/src/lib.rs +++ b/crates/gateway/src/lib.rs @@ -58,6 +58,9 @@ //! pending state, apply/revert, orphans, system, model-info, the HF //! proxy, reveal, shutdown) sits behind the shared loopback //! wall from `shared-loopback` in every build; with the +//! default-on `stt` feature, `WS /v1/realtime?intent=transcription` +//! serves Gateway-owned Realtime transcription beside the batch route; +//! with the //! `config-ui` feature the embedded config SPA is served at `/config/` //! behind the same wall, and `GET /auth?key=` sets a session proof //! derived from the bearer key as an HttpOnly cookie and redirects to the @@ -82,16 +85,17 @@ mod commands; mod config_apply; mod config_pending; mod config_write; +mod diagnostics; mod dialect; mod drain; mod env_file; mod error; mod handoff; mod hf; -#[cfg(feature = "local")] mod model_info; #[cfg(feature = "local")] mod orphans; +mod profile_switch; mod relaunch; mod render; mod reveal; @@ -116,6 +120,12 @@ pub(crate) use gateway_routing::queue; pub(crate) use gateway_local as local; pub use crate::api_error::{ServeError, StartupError, StartupErrorKind}; +pub use crate::diagnostics::diagnostics_json; +#[cfg(not(feature = "local"))] +pub(crate) use crate::profile_switch::LOCAL_MODELS_UNSUPPORTED; +#[cfg(not(feature = "stt"))] +pub(crate) use crate::profile_switch::STT_RUNTIME_UNAVAILABLE; +pub(crate) use crate::profile_switch::StatePersistence; pub use crate::relaunch::running_gateway_settings_url; pub use crate::runner::{ Gateway, GatewayHandle, ProfilesContext, ServeOptions, run, run_printing_url, spawn, @@ -130,10 +140,10 @@ use std::sync::Arc; use axum::Json; use axum::body::Body; -#[cfg(feature = "stt")] -use axum::extract::FromRequest; use axum::extract::State; use axum::http::HeaderValue; +#[cfg(feature = "stt")] +use axum::http::header::ORIGIN; use axum::http::header::{AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE}; use axum::response::Response; #[cfg(feature = "local")] @@ -149,14 +159,13 @@ use crate::error::GatewayError; use crate::local::LocalRuntime; use crate::routing::Routing; use crate::wire::{ - ChatRequest, EmbeddingRequest, EmbeddingResponse, ModelInfo, ModelsResponse, RerankRequest, - RerankResponse, + ChatRequest, EmbeddingRequest, EmbeddingResponse, ModelInfo, RerankRequest, RerankResponse, }; use gateway_config::ModelKind; #[cfg(feature = "web-search")] use gateway_config::WebSearchConfig; #[cfg(feature = "stt")] -use gateway_stt::{SttRuntime, SttState}; +use gateway_stt::SpeechService; #[cfg(feature = "web-search")] use gateway_web_search::{WebSearchRequest, WebSearchResponse, WebSearchState}; use shared_progress::{EventState, OperationId, ProgressEvent, ProgressHub, ProgressTree}; @@ -178,8 +187,6 @@ struct LiveState { web_search: Option>, #[cfg(feature = "local")] local: LocalRuntime, - #[cfg(feature = "stt")] - stt: Option, profile_name: Option, /// The active profile's `models` allowlist, when it declared one. model_allowlist: Option>, @@ -249,9 +256,10 @@ pub(crate) struct AppState { /// `POST /admin/config-apply`, the Apply command's commit, /// `POST /admin/config-revert`, and every shadow-writing `PUT` save /// serialize on it, so Apply only captures shadow combinations the - /// latest save validated whole and never half-promotes one. Held for - /// those short steps only, never across a download; profile loads do - /// not take it - the command queue already serializes them with Apply. + /// latest save validated whole and never half-promotes one. Profile + /// publication and pending reads also take it, so no reader can observe + /// authoritative files from one profile with the prior live snapshot. + /// Held for those short steps only, never across a download. apply: Arc>, /// The process-lifetime progress broker: operations attach trees for /// their own lifetimes, and `GET /admin/progress` streams its events. @@ -276,10 +284,9 @@ pub(crate) struct AppState { /// Process-lifetime random salt for the `/auth` handoff's session /// proof; a restart or key rotation invalidates every minted cookie. handoff_salt: [u8; 32], - /// Stable STT slot shared across runtime replacement on a profile - /// switch. + /// Process-lifetime speech facade shared by routes and profile switches. #[cfg(feature = "stt")] - stt_state: SttState, + speech: SpeechService, /// Test-only rendezvous the switch awaits at the start of one named /// phase, so a test can hold a switch inside the download, the /// cut-over, the spawn, or the commit and observe the lock and the live @@ -287,6 +294,10 @@ pub(crate) struct AppState { /// install one. #[cfg(test)] park: Option>, + /// Test-only transaction failure selected before the state is cloned into + /// a switch task. + #[cfg(test)] + switch_fault: Option, } /// The test-only phase rendezvous for [`run_switch_with_config`]. @@ -297,7 +308,7 @@ pub(crate) mod switch_park { /// One phase of the switch a test can park. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SwitchPhase { - /// The artifact download, before or after the cut-over by order. + /// The local artifact download, before or after cutover by order. Download, /// The cut-over, once the switch lock is held. CutOver, @@ -305,6 +316,15 @@ pub(crate) mod switch_park { Spawn, /// The commit, once the switch lock is held again. Commit, + /// The persistence-to-live-publication boundary. + Publish, + } + + /// One transaction failure a test can inject through the production path. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(crate) enum SwitchFault { + /// Runtime startup timed out after cutover and cannot be preempted. + StageIndeterminate, } /// Parks the switch at `phase` until the test releases it. Single use: @@ -355,6 +375,11 @@ impl AppState { } } + #[cfg(test)] + fn has_switch_fault(&self, fault: switch_park::SwitchFault) -> bool { + self.switch_fault == Some(fault) + } + /// Build full runtime state for `Gateway` and integration tests. #[must_use] #[expect( @@ -366,7 +391,7 @@ impl AppState { key: Secret, config: Arc, #[cfg(feature = "local")] local: LocalRuntime, - #[cfg(feature = "stt")] stt: SttRuntime, + #[cfg(feature = "stt")] speech: SpeechService, #[cfg(feature = "web-search")] web_search: Option<&WebSearchConfig>, config_path: Option, selection: ProfileSelection, @@ -375,8 +400,6 @@ impl AppState { let started = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |duration| duration.as_nanos()); - #[cfg(feature = "stt")] - let stt_state = stt.state(); AppState { live: Arc::new(RwLock::new(LiveState { routing, @@ -387,8 +410,6 @@ impl AppState { web_search: web_search.map(|cfg| Arc::new(WebSearchState::new(cfg))), #[cfg(feature = "local")] local, - #[cfg(feature = "stt")] - stt: Some(stt), profile_name: selection.name, model_allowlist: selection.model_allowlist, loading: BTreeSet::new(), @@ -414,9 +435,11 @@ impl AppState { salt }, #[cfg(feature = "stt")] - stt_state, + speech, #[cfg(test)] park: None, + #[cfg(test)] + switch_fault: None, } } @@ -477,14 +500,6 @@ pub(crate) fn build_router(state: AppState, bound: Option) "/admin/queue/cancel-pending", post(admin_queue_cancel_pending), ); - #[cfg(feature = "stt")] - let router = router.merge( - Router::new() - .route("/v1/audio/transcriptions", post(audio_transcriptions)) - .layer(axum::extract::DefaultBodyLimit::max( - gateway_stt::MAX_AUDIO_BYTES + 1024 * 1024, - )), - ); // The web-search tool route delegates to the service crate, so it exists // only in builds with the `web-search` feature. #[cfg(feature = "web-search")] @@ -562,12 +577,15 @@ pub(crate) fn build_router(state: AppState, bound: Option) #[cfg(feature = "config-ui")] let router = router.nest_service("/config/", gateway_config_ui::routes()); #[cfg(feature = "stt")] - let stt_state = state.stt_state.clone(); + let speech_routes = state.speech.routes(); let router = router.with_state(state.clone()); #[cfg(feature = "stt")] - let router = router.merge(gateway_stt::gateway_routes(stt_state).route_layer( - axum::middleware::from_fn_with_state(state, authorize_stt_route), - )); + let router = router.merge( + speech_routes.route_layer(axum::middleware::from_fn_with_state( + state, + authorize_stt_route, + )), + ); // The host-authority wall is the outermost layer, so a rebound // hostname is refused before any route logic runs. match bound { @@ -613,54 +631,44 @@ async fn health() -> impl IntoResponse { Json(serde_json::json!({ "status": "serving" })) } -/// OpenAI-compatible request-response transcription. -/// -/// Authentication runs before multipart extraction so an unauthorized caller -/// cannot make the gateway buffer or decode an audio body. #[cfg(feature = "stt")] -async fn audio_transcriptions( +async fn authorize_stt_route( State(state): State, caller: Caller, request: axum::extract::Request, + next: axum::middleware::Next, ) -> Result { check_auth(&state, &caller).await?; - let multipart = axum::extract::Multipart::from_request(request, &()) - .await - .map_err(|error| GatewayError::MalformedRequest(error.to_string()))?; + if request.uri().path() == "/v1/realtime" && !gateway_realtime_origin_allowed(&request) { + return Ok(axum::http::StatusCode::FORBIDDEN.into_response()); + } let in_flight = state.begin_inference().await; tokio::select! { - result = gateway_stt::transcribe(&state.stt_state, multipart) => { - result.map(IntoResponse::into_response).map_err(GatewayError::from) - } + response = next.run(request) => Ok(response), () = in_flight.cancelled() => Err(GatewayError::RequestCancelled), } } #[cfg(feature = "stt")] -async fn authorize_stt_route( - State(state): State, - caller: Caller, - request: axum::extract::Request, - next: axum::middleware::Next, -) -> Result { - check_auth(&state, &caller).await?; - Ok(next.run(request).await) +fn gateway_realtime_origin_allowed(request: &axum::extract::Request) -> bool { + let mut values = request.headers().get_all(ORIGIN).iter(); + let first = values.next(); + if values.next().is_some() { + return false; + } + let origin = match first { + None => None, + Some(value) => match value.to_str() { + Ok(value) => Some(value), + Err(_) => return false, + }, + }; + shared_loopback::gateway_loopback_origin_allowed(origin) } /// Header naming the caller for fair queue scheduling. Absent → `"default"`. const CLIENT_HEADER: &str = "X-PromptForge-Client"; -/// Error message when a configuration declaring `[[local_model]]` reaches a -/// build compiled without the `local` feature. -#[cfg(not(feature = "local"))] -const LOCAL_MODELS_UNSUPPORTED: &str = - "configuration declares [[local_model]] but this build lacks the `local` feature"; - -/// Error when STT reaches a gateway build without the heavy runtime. -#[cfg(not(feature = "stt"))] -const STT_RUNTIME_UNAVAILABLE: &str = - "the active profile selects [[stt_model]] but this build lacks the `stt` feature"; - /// Resolves a request's model name against the live routing table. /// /// A local model the running switch has cut over to but not yet spawned @@ -923,24 +931,39 @@ async fn rerank( async fn list_models( State(state): State, caller: Caller, -) -> Result, GatewayError> { +) -> Result, GatewayError> { check_auth(&state, &caller).await?; + let _publication = state.switch.lock().await; let live = state.live.read().await; let data = live .routing .models() .iter() - .map(|model| ModelInfo { - id: model.name.clone(), - object: "model", - kind: model.kind, - description: model.description.clone(), - context: model.context, - thinking: model.thinking, - capabilities: model.capabilities.clone(), + .map(|model| { + model_info::CatalogModelInfo::inference(ModelInfo { + id: model.name.clone(), + object: "model", + kind: model.kind, + description: model.description.clone(), + context: model.context, + thinking: model.thinking, + capabilities: model.capabilities.clone(), + }) }) - .collect(); - Ok(Json(ModelsResponse { + .collect::>(); + drop(live); + #[cfg(feature = "stt")] + let data = { + let mut data = data; + let speech_models = state.speech.models(); + data.extend( + speech_models + .iter() + .map(model_info::CatalogModelInfo::speech), + ); + data + }; + Ok(Json(model_info::CatalogModelsResponse { object: "list", data, })) @@ -999,6 +1022,22 @@ fn endpoint_status( } } +#[cfg(feature = "stt")] +fn with_speech_endpoint( + mut endpoints: Vec, + speech: gateway_stt::SpeechStatus, + command_active: bool, +) -> (Vec, gateway_stt::SpeechStatus) { + endpoints.push(endpoint_status( + "/v1/audio/transcriptions", + "Audio transcriptions", + speech.configured(), + speech.ready(), + command_active, + )); + (endpoints, speech) +} + /// An `Instant` as Unix epoch seconds for the status wire shape. The /// conversion goes through the elapsed duration, so a clock that jumped /// backward clamps to now rather than underflowing. @@ -1021,6 +1060,7 @@ async fn admin_status( caller: Caller, ) -> Result, GatewayError> { check_auth(&state, &caller).await?; + let _publication = state.switch.lock().await; let active = state.commands.active_command(); let pending = state.commands.pending_commands(); let live = state.live.read().await; @@ -1074,18 +1114,9 @@ async fn admin_status( ), ]; #[cfg(feature = "stt")] - let endpoints = { - let mut endpoints = endpoints; - endpoints.push(endpoint_status( - "/v1/audio/transcriptions", - "Audio transcriptions", - !live.config.stt_models().is_empty(), - state.stt_state.is_active(), - command_active, - )); - endpoints - }; - Ok(Json(serde_json::json!({ + let (endpoints, speech) = + with_speech_endpoint(endpoints, state.speech.status(), command_active); + let response = serde_json::json!({ "profile": live.profile_name, "models": models, "loading_models": live.loading.iter().collect::>(), @@ -1116,7 +1147,14 @@ async fn admin_status( "provisioning": endpoint.provisioning, })) .collect::>(), - }))) + }); + #[cfg(feature = "stt")] + let response = { + let mut response = response; + response["speech"] = serde_json::json!(system::SpeechSnapshot::from(speech)); + response + }; + Ok(Json(response)) } /// The `POST /admin/queue/cancel` route: bearer-authed, fires the active @@ -1183,7 +1221,8 @@ const PROGRESS_HEARTBEAT: std::time::Duration = std::time::Duration::from_secs(1 /// as synthetic `Begun`/`Updated` events, plus a `Finished` for each leaf /// that already reached its terminal state, so it can render current state /// without waiting for the next event, and then every broadcast -/// [`ProgressEvent`], with heartbeat comment lines every +/// [`ProgressEvent`], including one operation-level terminal event when a +/// tree detaches, with heartbeat comment lines every /// [`PROGRESS_HEARTBEAT`] while the hub is idle. Intermediate events are /// lossy - a lagging subscriber drops them - and terminal events are never /// coalesced at the source. Client disconnect is Drop all the way down, as @@ -1304,7 +1343,7 @@ fn event_line(event: &ProgressEvent) -> Option { /// phase in execution order - `loading-profile` around config load and /// validation, `downloading-models` while the new local models' weights /// stage into the cache (only when the profile names local models), -/// `stopping-models` before the old local children and STT engine shut +/// `stopping-models` before the old local children and speech generation shut /// down (only when there are any to stop), `starting-models` before the new /// children load their weights into VRAM (the long pole) - and the stream /// ends with exactly one terminal event, `{"status": "ready", "profile": @@ -1353,18 +1392,6 @@ async fn admin_switch_profile( Ok(switch_sse_response(rx, enqueued.operation, switch)) } -/// How a successful switch commits its active-profile state. -pub(crate) enum StatePersistence { - /// The selection already matches persisted state. - None, - /// Atomically replace real state while preserving any pending shadow. - Write, - /// Promote the shadows an Apply captured: each capture's contents land in - /// its real file, and the shadow is deleted only when it still holds - /// those contents, so a save that raced the apply stays pending. - Promote(Vec), -} - /// Executes a switch using an optional catalog parsed by Apply. /// /// The switch runs in five phases and holds the `switch` lock - the one @@ -1373,27 +1400,29 @@ pub(crate) enum StatePersistence { /// spawn: /// /// 1. **Prepare** (unlocked): the `loading-profile` leaf, the catalog, the -/// target profile's config and remote routing table. +/// target profile's config, remote routing table, and speech artifacts. /// 2. **Download** (unlocked): every artifact the new local models need, /// under a `downloading-models` leaf, through the same artifact store /// the `ProvisionModel` command uses. Cancellation lands at chunk -/// boundaries. +/// boundaries. Synced persistence temporaries are prepared before cutover. /// 3. **Cut over** (locked, bounded): the bounded drain, then the old -/// local and STT runtimes stop under a `stopping-models` leaf (only +/// local runtimes stop under a +/// `stopping-models` leaf (only /// registered when there is something to stop), and one `live.write` /// publishes the interim state: the new profile's remote models as the /// routing table, the surviving runtimes, and the local models about to /// spawn as [`LiveState::loading`]. -/// 4. **Spawn** (unlocked): the new children start and reach readiness -/// under `starting-models`. A request for a model in `loading` earns +/// 4. **Spawn** (unlocked): speech quiesces its old generation without +/// detachment, then all target workers start under one deadline. A request for a model in `loading` earns /// [`GatewayError::ModelLoading`] (503, `Retry-After`); remote models /// serve. -/// 5. **Commit** (locked, brief): [`commit_profile_state`], then one +/// 5. **Commit** (locked, brief): prepared files atomically replace their +/// authoritative targets, then one /// `live.write` swaps in the full routing table, the runtimes, the /// profile, and clears `loading`. /// /// Ordering: the cut-over runs as soon as there is nothing old to stop. -/// When the live state holds no local children and no STT engine (a cold +/// When the live state holds no local children and no speech generation (a cold /// boot, or a remote-only previous profile) phase 3 follows phase 1 /// directly, so the remote models are published before the download /// starts. Otherwise the download runs first, so the old runtimes keep @@ -1401,22 +1430,19 @@ pub(crate) enum StatePersistence { /// runtimes stop only right before the new ones spawn, never before a /// download. /// -/// Failure or cancellation after the cut-over - in the download (early -/// order), the spawn, or the commit - clears `loading`, so requests fall -/// through to a 404 rather than a permanent 503, keeps the interim remote -/// routing live, and drops any child that did start: after such a switch -/// the gateway serves the new profile's remote models and no local ones -/// until the next switch. A partial start (some children ready, others +/// Determinate failure or cancellation after cutover reconstructs speech, +/// restores the prior routing snapshot, and drops target workers. Indeterminate +/// persistence or non-preemptible staging timeout invalidates replacement +/// and requests controlled shutdown. A partial start (some children ready, others /// failed) is not that case: as before, it commits and swaps the ready /// children in, and reports the rest through [`GatewayError::PartialStart`]. /// A failure before the cut-over leaves the live state untouched. /// /// `token` is the command's cancellation: checked at phase boundaries and /// honored by the download and the local start, so a cancelled switch -/// stops instead of running its remaining phases. `persistence` is -/// evaluated once, at commit time, so a debounced queue duplicate can -/// upgrade an ephemeral load into a persisted one while the switch is -/// still running. +/// stops instead of running its remaining phases. `persistence` is evaluated +/// once before cutover, so a debounced duplicate can upgrade an ephemeral +/// load until destructive replacement begins. async fn run_switch_with_config( state: AppState, name: ProfileName, @@ -1425,827 +1451,160 @@ async fn run_switch_with_config( persistence: impl FnOnce() -> StatePersistence, token: &tokio_util::sync::CancellationToken, ) -> Result { - // A cancelled command stops at phase boundaries rather than midway. - if token.is_cancelled() { - return Err(switch_cancelled(&name)); - } - let target = prepare_switch(&state, &name, &tree, candidate).await?; - if token.is_cancelled() { - return Err(switch_cancelled(&name)); - } - - // Every phase past the first may run after the interim state is - // published, so a failure anywhere in them clears `loading`; before the - // cut-over the set is still empty and the clear touches nothing. - let outcome = run_switch_phases(&state, &name, &tree, target, persistence, token).await; - let report = match outcome { - Ok(report) => report, - Err(error) => { - clear_loading(&state).await; - return Err(error); - } - }; - - #[cfg(feature = "local")] - if !report.failed.is_empty() { - return Err(GatewayError::PartialStart { - profile: name.to_string(), - loaded: report.loaded, - failed: report.failed, - }); - } - #[cfg(not(feature = "local"))] - let StartReport {} = report; + profile_switch::run(&state, name, tree, candidate, persistence, token).await +} - tracing::info!(profile = %name, "switched profile"); - Ok(name.to_string()) +/// Builds the switch-profile SSE response: the hub's event stream filtered +/// to this switch's operation, each leaf's `Begun` re-emitted as the +/// `{"stage": ...}` event the route has always carried, then the terminal +/// event from the command's settled outcome, so the outcome can never be +/// lost to broadcast lag. +/// +/// The queue worker broadcasts every stage event before settling the +/// command, so once the outcome resolves the remaining stages are already +/// queued on the receiver and are drained ahead of the terminal event. +fn switch_sse_response( + rx: tokio::sync::broadcast::Receiver, + operation: OperationId, + switch: tokio::task::JoinHandle, +) -> Response { + let stream = futures_util::stream::unfold( + (rx, switch, std::collections::VecDeque::new(), false), + move |(mut rx, mut switch, mut pending, mut done)| async move { + loop { + if let Some(line) = pending.pop_front() { + return Some(( + Ok::<_, std::convert::Infallible>(line), + (rx, switch, pending, done), + )); + } + if done { + return None; + } + let result = loop { + tokio::select! { + received = rx.recv() => match received { + Ok(event) => { + if event.operation == operation + && matches!(event.state, EventState::Begun { .. }) + { + return Some(( + Ok(stage_line(&event)), + (rx, switch, pending, done), + )); + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + tracing::debug!(skipped, "switch stage subscriber lagged; events dropped"); + } + // The hub lives in `AppState` for the process + // lifetime, so its sender never closes first; the + // join result still carries the outcome if it did. + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + break (&mut switch).await; + } + }, + result = &mut switch => break result, + } + }; + drain_switch_stages(&mut rx, operation, &mut pending); + done = true; + pending.push_back(terminal_line(result)); + } + }, + ); + let mut response = Response::new(Body::from_stream(stream)); + let headers = response.headers_mut(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/event-stream")); + headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache")); + response } -/// Phases 2 to 5 of [`run_switch_with_config`], in the order the stop set -/// dictates. Returns the spawn's per-model report once the commit landed. -async fn run_switch_phases( - state: &AppState, - name: &ProfileName, - tree: &ProgressTree, - target: SwitchTarget, - persistence: impl FnOnce() -> StatePersistence, - token: &tokio_util::sync::CancellationToken, -) -> Result { - let stop = stop_set(state).await; - if stop.is_empty() { - cut_over(state, &target, tree, stop, token).await?; - #[cfg(test)] - { - state.park_at(switch_park::SwitchPhase::Download).await; - } - download_artifacts(&target, tree, token).await?; - } else { - #[cfg(test)] - { - state.park_at(switch_park::SwitchPhase::Download).await; +/// Drains stage events already queued when the switch task completes. +/// +/// A lag marker describes dropped older events, not an empty receiver, so +/// catch-up continues after it and preserves every retained stage. +fn drain_switch_stages( + rx: &mut tokio::sync::broadcast::Receiver, + operation: OperationId, + pending: &mut std::collections::VecDeque, +) { + loop { + match rx.try_recv() { + Ok(event) + if event.operation == operation + && matches!(event.state, EventState::Begun { .. }) => + { + pending.push_back(stage_line(&event)); + } + Ok(_) | Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => {} + Err( + tokio::sync::broadcast::error::TryRecvError::Empty + | tokio::sync::broadcast::error::TryRecvError::Closed, + ) => break, } - download_artifacts(&target, tree, token).await?; - cut_over(state, &target, tree, stop, token).await?; - } - // Phase boundary: start no replacement children for a cancelled command. - if token.is_cancelled() { - return Err(switch_cancelled(name)); } - #[cfg(test)] - { - state.park_at(switch_park::SwitchPhase::Spawn).await; - } - let replacement = spawn_runtimes( - &target.config, - #[cfg(feature = "stt")] - state.stt_state.clone(), - tree, - token, - ) - .await?; - // Phase boundary: a token fired during the start stops before the - // persist and the swap; dropping the replacement tears down any - // children it started. - if token.is_cancelled() { - return Err(switch_cancelled(name)); - } - commit_switch(state, name, target, replacement, persistence(), token).await -} - -/// The cancellation a switch reports when its token fires at a phase -/// boundary. -fn switch_cancelled(name: &ProfileName) -> GatewayError { - GatewayError::CommandCancelled(format!("load-profile: {name}")) } -/// Everything phase 1 resolves for the later phases. -struct SwitchTarget { - /// The target profile's selected config. - config: Config, - /// The target profile's remote models: the interim routing table at - /// cut-over, and the base the local models merge into at commit. - remote_routing: Routing, - #[cfg(feature = "web-search")] - web_search: Option>, - allowlist: Option>, - /// The local models the spawn will start, published as - /// [`LiveState::loading`] at cut-over. - loading: BTreeSet, +/// Maps a leaf's `Begun` to the switch stream's stage event. +fn stage_line(event: &ProgressEvent) -> String { + format!("data: {}\n\n", serde_json::json!({ "stage": event.label })) } -/// Phase 1: resolves the target profile from the catalog, unlocked. -async fn prepare_switch( - state: &AppState, - name: &ProfileName, - tree: &ProgressTree, - candidate: Option, -) -> Result { - // Each phase registers its leaf as it opens, so the leaf's `Begun` is the - // stage marker and a failed switch never announces a phase it did not - // reach. Weights track expected duration: the download and the start - // are the long poles. - let loading = tree.register("loading-profile", 1.0); - let catalog = match candidate { - Some(config) => config, - None => state.live.read().await.config.as_ref().clone(), +/// Maps the switch command's settled outcome to the stream's terminal event. +fn terminal_line(result: Result) -> String { + let payload = match result { + Ok(outcome) => match &*outcome { + Ok(profile) => serde_json::json!({ "status": "ready", "profile": profile }), + #[cfg(feature = "local")] + Err(GatewayError::PartialStart { + profile, + loaded, + failed, + }) => serde_json::json!({ + "status": "error", + "profile": profile, + "loaded": loaded, + "failed": failed, + }), + Err(error) => serde_json::json!({ + "status": "error", + "message": error_chain(error), + }), + }, + Err(join_error) => serde_json::json!({ + "status": "error", + "message": format!("switch task failed: {join_error}"), + }), }; - let (config, remote_routing) = prepare_switch_target(&catalog, name, &loading)?; - // A headless build cannot honor a profile declaring local models; refuse - // the switch rather than silently dropping them. - #[cfg(not(feature = "local"))] - if !config.local_models().is_empty() { - loading.fail(); - return Err(GatewayError::switch_failed( - "start-local", - std::io::Error::other(LOCAL_MODELS_UNSUPPORTED), - )); - } - loading.complete(); - - #[cfg(feature = "web-search")] - let web_search = config - .web_search_config() - .map(WebSearchState::new) - .map(Arc::new); - let allowlist = config - .active_profile() - .map(|profile| profile.models().to_vec()); - let loading = config - .local_models() - .iter() - .map(|model| model.name().to_owned()) - .collect(); - Ok(SwitchTarget { - config, - remote_routing, - #[cfg(feature = "web-search")] - web_search, - allowlist, - loading, - }) -} - -/// Which old runtimes the cut-over must stop. -#[derive(Debug, Clone, Copy)] -struct StopSet { - #[cfg(feature = "local")] - local: bool, - #[cfg(feature = "stt")] - stt: bool, + format!("data: {payload}\n\n") } -impl StopSet { - /// Whether nothing old is running: the cut-over then costs no stop and - /// runs before the download. - fn is_empty(self) -> bool { - let any = false; - #[cfg(feature = "local")] - let any = any || self.local; - #[cfg(feature = "stt")] - let any = any || self.stt; - !any - } -} +/// Renders `error` with its full source chain for the terminal SSE error +/// event: the stream has a single `message` field where the JSON envelope +/// had `message` plus `code`, and a bare `switch profile failed at +/// load-profile` without its cause tells the operator nothing. +fn error_chain(error: &GatewayError) -> String { + use std::fmt::Write as _; -/// Reads which old runtimes the live state holds. -#[cfg(any(feature = "local", feature = "stt"))] -async fn stop_set(state: &AppState) -> StopSet { - #[cfg(feature = "local")] - let live = state.live.read().await; - StopSet { - #[cfg(feature = "local")] - local: live.local.child_count() > 0, - #[cfg(feature = "stt")] - stt: state.stt_state.is_active(), + let mut message = error.to_string(); + let mut source = std::error::Error::source(error); + while let Some(cause) = source { + let _ = write!(message, ": {cause}"); + source = cause.source(); } + message } -/// A headless build runs no local or STT runtime, so there is never -/// anything to stop. -#[cfg(not(any(feature = "local", feature = "stt")))] -async fn stop_set(_state: &AppState) -> StopSet { - StopSet {} +fn config_path(state: &AppState) -> Result<&std::path::Path, GatewayError> { + state + .config + .as_ref() + .map(|config| config.path.as_path()) + .ok_or(GatewayError::ConfigPathUnavailable) } -/// Phase 2: stages every artifact the target's local models need, unlocked, -/// through the same store and entry points the `ProvisionModel` command and -/// the local start use, so the start that follows finds every blob cached. -/// -/// A per-model provisioning failure is not fatal here: the start re-runs -/// the same ensure, fails the same way, and reports it through -/// `PartialStart`, so the models that did provision still start. The leaf -/// records the fault so the stage shows it. -#[cfg(feature = "local")] -async fn download_artifacts( - target: &SwitchTarget, - tree: &ProgressTree, - token: &tokio_util::sync::CancellationToken, -) -> Result<(), GatewayError> { - if target.config.local_models().is_empty() { - return Ok(()); - } - let downloading = tree.register("downloading-models", 5.0); - let config = target.config.clone(); - let progress = downloading.clone(); - let worker_token = token.clone(); - let result = tokio::task::spawn_blocking(move || { - local::LocalRuntime::provision_artifacts_with_cancellation( - &config, - Some(&progress), - &worker_token, - ) - }) - .await; - match result { - Ok(Ok(failures)) if failures.is_empty() => { - downloading.complete(); - Ok(()) - } - Ok(Ok(failures)) => { - for failure in &failures { - tracing::warn!( - model = failure.model(), - error = %failure.error(), - "local model artifact did not provision; the start reports it" - ); - } - downloading.fail(); - Ok(()) - } - Ok(Err(error)) => { - downloading.fail(); - Err(GatewayError::switch_failed("download-models", error)) - } - Err(error) => { - downloading.fail(); - Err(GatewayError::switch_failed("download-models-task", error)) - } - } -} - -/// Phase 2 in a headless build: nothing to stage, since phase 1 already -/// refused a profile naming local models. -#[cfg(not(feature = "local"))] -async fn download_artifacts( - _target: &SwitchTarget, - _tree: &ProgressTree, - _token: &tokio_util::sync::CancellationToken, -) -> Result<(), GatewayError> { - Ok(()) -} - -/// Phase 3: the cut-over, under the switch lock. Drains in-flight -/// inference (bounded), publishes the interim live state in one write - -/// the target's remote models as the routing table, the old runtimes taken -/// out, the local models to come as `loading` - and then stops the old -/// runtimes it took out, under `stopping-models`. With nothing to stop the -/// leaf is not registered and the write is the whole phase. -/// -/// The drain precedes the stop: in-flight requests hold their own `Arc` of -/// the old table entries, so the swap does not disturb them, but the stop -/// would kill the children under them. -async fn cut_over( - state: &AppState, - target: &SwitchTarget, - tree: &ProgressTree, - stop: StopSet, - token: &tokio_util::sync::CancellationToken, -) -> Result<(), GatewayError> { - let _switch = state.switch.lock().await; - #[cfg(test)] - { - state.park_at(switch_park::SwitchPhase::CutOver).await; - } - // A cancelled command stops waiting on the drain: the replacement - // switch (or the shutdown path) re-drains what it needs. - tokio::select! { - () = drain_inference(state) => {} - () = token.cancelled() => { - return Err(GatewayError::CommandCancelled("profile switch".to_owned())); - } - } - let stopping = if stop.is_empty() { - None - } else { - Some(tree.register("stopping-models", 2.0)) - }; - let old = { - let mut live = state.live.write().await; - live.routing = Arc::new(target.remote_routing.clone()); - live.loading.clone_from(&target.loading); - if stopping.is_none() { - None - } else { - Some(OldRuntimes { - #[cfg(feature = "local")] - local: std::mem::replace(&mut live.local, LocalRuntime::empty()), - #[cfg(feature = "stt")] - stt: live.stt.take(), - }) - } - }; - let (Some(stopping), Some(old)) = (stopping, old) else { - return Ok(()); - }; - // The routing table also owns each local upstream. Explicit shutdown - // disables respawn and frees all old-profile VRAM before replacements - // start (PFGL-MOD-001). - match tokio::task::spawn_blocking(move || old.shutdown()).await { - Ok(Ok(())) => { - stopping.complete(); - Ok(()) - } - Ok(Err(error)) => { - stopping.fail(); - Err(GatewayError::switch_failed("shutdown-local", error)) - } - Err(error) => { - stopping.fail(); - Err(GatewayError::switch_failed("shutdown-local-task", error)) - } - } -} - -/// The runtimes the cut-over took out of the live state, to stop off the -/// async executor. -struct OldRuntimes { - #[cfg(feature = "local")] - local: LocalRuntime, - #[cfg(feature = "stt")] - stt: Option, -} - -impl OldRuntimes { - /// Stops every old runtime, STT first so its engine memory is released - /// before the local children's teardown is awaited. - fn shutdown(self) -> Result<(), shared_protocol::ShutdownError> { - #[cfg(feature = "stt")] - if let Some(runtime) = self.stt { - runtime.shutdown(); - } - #[cfg(feature = "local")] - let result = self.local.shutdown(); - #[cfg(not(feature = "local"))] - let result = Ok(()); - result - } -} - -/// Clears [`LiveState::loading`] after a switch failed past its cut-over, -/// so the models it promised fall through to a 404 instead of a permanent -/// 503. Before the cut-over the set is empty and this changes nothing. -async fn clear_loading(state: &AppState) { - state.live.write().await.loading.clear(); -} - -/// Phase 5: the commit, under the switch lock. Merges the started local -/// models into the remote table, persists the profile selection, and swaps -/// the whole new profile into the live state in one write, clearing -/// `loading`. Persistence precedes the swap: once the state file commits -/// the swap is infallible, so another switch can never overwrite pending -/// state between activation and persistence. -async fn commit_switch( - state: &AppState, - name: &ProfileName, - target: SwitchTarget, - replacement: RuntimeReplacement, - persistence: StatePersistence, - token: &tokio_util::sync::CancellationToken, -) -> Result { - let _switch = state.switch.lock().await; - #[cfg(test)] - { - state.park_at(switch_park::SwitchPhase::Commit).await; - } - #[cfg(feature = "local")] - let routing = target - .remote_routing - .merge(replacement.local.models().iter().cloned()) - .map_err(|e| GatewayError::switch_failed("merge-routing", e))?; - #[cfg(not(feature = "local"))] - let routing = target.remote_routing; - #[cfg(not(any(feature = "local", feature = "stt")))] - let RuntimeReplacement {} = replacement; - commit_profile_state(state, name, persistence, token).await?; - - #[cfg(feature = "local")] - let report = StartReport { - loaded: replacement - .local - .models() - .iter() - .map(|model| model.name.clone()) - .collect(), - failed: replacement - .start_failures - .iter() - .map(|failure| format!("{}: {}", failure.model(), failure.error())) - .collect(), - }; - #[cfg(not(feature = "local"))] - let report = StartReport {}; - - // Atomic swap: commit the whole new profile at once. - let mut live = state.live.write().await; - live.routing = Arc::new(routing); - // The listener and bearer key are process-owned `[server]` state. - // Apply reports their edits as restart-required, so a profile reload - // must not change authentication before that restart. - live.config = Arc::new(target.config); - #[cfg(feature = "web-search")] - { - live.web_search = target.web_search; - } - #[cfg(feature = "local")] - { - live.local = replacement.local; - } - #[cfg(feature = "stt")] - { - live.stt = Some(replacement.stt); - } - live.profile_name = Some(name.to_string()); - live.model_allowlist = target.allowlist; - live.loading.clear(); - Ok(report) -} - -/// What the spawn reported once the commit landed: the local models that -/// reached readiness and the ones that failed, rendered for -/// [`GatewayError::PartialStart`]. -struct StartReport { - #[cfg(feature = "local")] - loaded: Vec, - #[cfg(feature = "local")] - failed: Vec, -} - -fn prepare_switch_target( - catalog: &Config, - name: &ProfileName, - loading: &shared_progress::ProgressHandle, -) -> Result<(Config, Routing), GatewayError> { - if !catalog - .profiles() - .iter() - .any(|profile| profile.name() == name.as_str()) - { - loading.fail(); - return Err(GatewayError::ProfileNotFound(name.to_string())); - } - let config = match catalog.select_profile(name) { - Ok(config) => config, - Err(error) => { - loading.fail(); - return Err(GatewayError::switch_failed("select-profile", error)); - } - }; - #[cfg(not(feature = "stt"))] - if !config.stt_models().is_empty() { - loading.fail(); - return Err(GatewayError::switch_failed( - "start-stt", - std::io::Error::other(STT_RUNTIME_UNAVAILABLE), - )); - } - let remote_routing = match Routing::from_config(&config) { - Ok(routing) => routing, - Err(error) => { - loading.fail(); - return Err(GatewayError::switch_failed("build-routing", error)); - } - }; - Ok((config, remote_routing)) -} - -async fn drain_inference(state: &AppState) { - if !state - .in_flight - .drain_or_cancel(std::time::Duration::from_secs(30)) - .await - { - tracing::warn!( - "profile-switch cancellation grace expired; stopping local children with request guards still registered" - ); - } -} - -/// The runtimes phase 4 started, swapped into the live state at commit. -struct RuntimeReplacement { - #[cfg(feature = "local")] - local: LocalRuntime, - #[cfg(feature = "local")] - start_failures: Vec, - #[cfg(feature = "stt")] - stt: SttRuntime, -} - -/// Phase 4 in a headless build: no local or STT runtime exists to start, -/// and no `starting-models` leaf is registered. -#[cfg(not(any(feature = "local", feature = "stt")))] -async fn spawn_runtimes( - _config: &Config, - _tree: &ProgressTree, - _token: &tokio_util::sync::CancellationToken, -) -> Result { - Ok(RuntimeReplacement {}) -} - -/// Phase 4: starts the target's local children and STT engine and waits -/// for readiness under `starting-models`, unlocked. The artifacts were -/// staged by phase 2, so the start's own ensure calls are cache hits and -/// the phase is the spawn and the weight load. -#[cfg(any(feature = "local", feature = "stt"))] -async fn spawn_runtimes( - config: &Config, - #[cfg(feature = "stt")] stt_state: SttState, - tree: &ProgressTree, - token: &tokio_util::sync::CancellationToken, -) -> Result { - let starting = tree.register("starting-models", 5.0); - #[cfg(feature = "local")] - let start_config = config.clone(); - #[cfg(feature = "local")] - let start_progress = starting.clone(); - #[cfg(feature = "local")] - let outcome = { - // The child readiness poll predates the token and speaks - // `AtomicBool`; the bridge task folds the token into the flag so one - // cancellation source stops a child still loading weights. - let start_token = token.clone(); - let interrupted = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let bridge = tokio::spawn({ - let interrupted = Arc::clone(&interrupted); - let token = token.clone(); - async move { - token.cancelled().await; - interrupted.store(true, std::sync::atomic::Ordering::Release); - } - }); - let result = tokio::task::spawn_blocking(move || { - local::LocalRuntime::start_partial_with_cancellation( - &start_config, - Some(&start_progress), - &start_token, - &interrupted, - ) - }) - .await; - bridge.abort(); - match result { - Ok(Ok(outcome)) => outcome, - Ok(Err(error)) => { - starting.fail(); - return Err(GatewayError::switch_failed("start-local", error)); - } - Err(error) => { - starting.fail(); - return Err(GatewayError::switch_failed("start-local-task", error)); - } - } - }; - #[cfg(feature = "local")] - let (runtime, failures) = outcome.into_parts(); - // Phase boundary: a cancelled command starts no STT runtime behind the - // cancellation; the local runtime built above drops, killing its - // children. - #[cfg(feature = "stt")] - if token.is_cancelled() { - return Err(GatewayError::CommandCancelled("profile switch".to_owned())); - } - #[cfg(feature = "stt")] - let stt_config = config.clone(); - #[cfg(feature = "stt")] - let stt_progress = starting.clone(); - #[cfg(feature = "stt")] - let stt = match tokio::task::spawn_blocking(move || { - SttRuntime::start(&stt_config, stt_state, Some(&stt_progress)) - }) - .await - { - Ok(Ok(runtime)) => runtime, - Ok(Err(error)) => { - starting.fail(); - return Err(GatewayError::switch_failed("start-stt", error)); - } - Err(error) => { - starting.fail(); - return Err(GatewayError::switch_failed("start-stt-task", error)); - } - }; - #[cfg(feature = "local")] - if failures.is_empty() { - starting.complete(); - } else { - starting.fail(); - } - #[cfg(not(feature = "local"))] - starting.complete(); - Ok(RuntimeReplacement { - #[cfg(feature = "local")] - local: runtime, - #[cfg(feature = "local")] - start_failures: failures, - #[cfg(feature = "stt")] - stt, - }) -} - -/// Commits active-profile state while the caller holds the switch lock. -/// -/// The `Promote` arm takes the apply lock for the commit alone - never -/// across a download - so it serializes with saves and revert, and it -/// re-checks `token` under that lock: a revert that fired the token while -/// this commit waited for the lock must win, or the commit would write the -/// snapshot over files the user just reverted. -async fn commit_profile_state( - state: &AppState, - name: &ProfileName, - persistence: StatePersistence, - token: &tokio_util::sync::CancellationToken, -) -> Result<(), GatewayError> { - match persistence { - StatePersistence::None => Ok(()), - StatePersistence::Write => persist_active_profile(state, name).await, - StatePersistence::Promote(captures) => { - let _apply = state.apply.lock().await; - if token.is_cancelled() { - return Err(GatewayError::CommandCancelled( - commands::APPLY_CONFIG_LABEL.to_owned(), - )); - } - tokio::task::spawn_blocking(move || config_apply::promote_captures(&captures)) - .await - .map_err(|join| GatewayError::ConfigWriteIo(Box::new(join)))??; - Ok(()) - } - } -} - -/// Persists the active profile beside the single configuration file. -async fn persist_active_profile(state: &AppState, name: &ProfileName) -> Result<(), GatewayError> { - let Some(config) = state.config.as_ref() else { - return Ok(()); - }; - let config_path = config.path.clone(); - let name = name.clone(); - tokio::task::spawn_blocking(move || gateway_config::persist_profile_state(&config_path, &name)) - .await - .map_err(|join| GatewayError::ConfigWriteIo(Box::new(join)))? - .map_err(config_write::config_write_error) -} - -/// Builds the switch-profile SSE response: the hub's event stream filtered -/// to this switch's operation, each leaf's `Begun` re-emitted as the -/// `{"stage": ...}` event the route has always carried, then the terminal -/// event from the command's settled outcome, so the outcome can never be -/// lost to broadcast lag. -/// -/// The queue worker broadcasts every stage event before settling the -/// command, so once the outcome resolves the remaining stages are already -/// queued on the receiver and are drained ahead of the terminal event. -fn switch_sse_response( - rx: tokio::sync::broadcast::Receiver, - operation: OperationId, - switch: tokio::task::JoinHandle, -) -> Response { - let stream = futures_util::stream::unfold( - (rx, switch, std::collections::VecDeque::new(), false), - move |(mut rx, mut switch, mut pending, mut done)| async move { - loop { - if let Some(line) = pending.pop_front() { - return Some(( - Ok::<_, std::convert::Infallible>(line), - (rx, switch, pending, done), - )); - } - if done { - return None; - } - let result = loop { - tokio::select! { - received = rx.recv() => match received { - Ok(event) => { - if event.operation == operation - && matches!(event.state, EventState::Begun { .. }) - { - return Some(( - Ok(stage_line(&event)), - (rx, switch, pending, done), - )); - } - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { - tracing::debug!(skipped, "switch stage subscriber lagged; events dropped"); - } - // The hub lives in `AppState` for the process - // lifetime, so its sender never closes first; the - // join result still carries the outcome if it did. - Err(tokio::sync::broadcast::error::RecvError::Closed) => { - break (&mut switch).await; - } - }, - result = &mut switch => break result, - } - }; - drain_switch_stages(&mut rx, operation, &mut pending); - done = true; - pending.push_back(terminal_line(result)); - } - }, - ); - let mut response = Response::new(Body::from_stream(stream)); - let headers = response.headers_mut(); - headers.insert(CONTENT_TYPE, HeaderValue::from_static("text/event-stream")); - headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache")); - response -} - -/// Drains stage events already queued when the switch task completes. -/// -/// A lag marker describes dropped older events, not an empty receiver, so -/// catch-up continues after it and preserves every retained stage. -fn drain_switch_stages( - rx: &mut tokio::sync::broadcast::Receiver, - operation: OperationId, - pending: &mut std::collections::VecDeque, -) { - loop { - match rx.try_recv() { - Ok(event) - if event.operation == operation - && matches!(event.state, EventState::Begun { .. }) => - { - pending.push_back(stage_line(&event)); - } - Ok(_) | Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => {} - Err( - tokio::sync::broadcast::error::TryRecvError::Empty - | tokio::sync::broadcast::error::TryRecvError::Closed, - ) => break, - } - } -} - -/// Maps a leaf's `Begun` to the switch stream's stage event. -fn stage_line(event: &ProgressEvent) -> String { - format!("data: {}\n\n", serde_json::json!({ "stage": event.label })) -} - -/// Maps the switch command's settled outcome to the stream's terminal event. -fn terminal_line(result: Result) -> String { - let payload = match result { - Ok(outcome) => match &*outcome { - Ok(profile) => serde_json::json!({ "status": "ready", "profile": profile }), - #[cfg(feature = "local")] - Err(GatewayError::PartialStart { - profile, - loaded, - failed, - }) => serde_json::json!({ - "status": "error", - "profile": profile, - "loaded": loaded, - "failed": failed, - }), - Err(error) => serde_json::json!({ - "status": "error", - "message": error_chain(error), - }), - }, - Err(join_error) => serde_json::json!({ - "status": "error", - "message": format!("switch task failed: {join_error}"), - }), - }; - format!("data: {payload}\n\n") -} - -/// Renders `error` with its full source chain for the terminal SSE error -/// event: the stream has a single `message` field where the JSON envelope -/// had `message` plus `code`, and a bare `switch profile failed at -/// load-profile` without its cause tells the operator nothing. -fn error_chain(error: &GatewayError) -> String { - use std::fmt::Write as _; - - let mut message = error.to_string(); - let mut source = std::error::Error::source(error); - while let Some(cause) = source { - let _ = write!(message, ": {cause}"); - source = cause.source(); - } - message -} - -fn config_path(state: &AppState) -> Result<&std::path::Path, GatewayError> { - state - .config - .as_ref() - .map(|config| config.path.as_path()) - .ok_or(GatewayError::ConfigPathUnavailable) -} - -/// Authenticates the caller by any one of three rules, in this order. +/// Authenticates the caller by any one of three rules, in this order. /// /// 1. A presented bearer token equals the live key. /// 2. The `/auth` browser handoff's cookie verifies: the cookie is the @@ -2379,34 +1738,72 @@ mod transcription_auth_tests { } #[tokio::test] - async fn stt_capability_is_mounted_behind_bearer_auth() { - let unauthorized = build_router(state(), None) + async fn authenticated_multipart_rejection_uses_the_openai_error_envelope() { + let response = build_router(state(), None) .oneshot( Request::builder() - .uri("/stt/capability") - .body(Body::empty()) + .method("POST") + .uri("/v1/audio/transcriptions") + .header("authorization", "Bearer test-token") + .header("content-type", "not-multipart") + .body(Body::from("not multipart")) .expect("request builds"), ) .await .expect("router answers"); - assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED); - let authorized = build_router(state(), None) + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("body reads"); + let json: serde_json::Value = serde_json::from_slice(&body).expect("body is JSON"); + assert_eq!( + json, + serde_json::json!({ + "error": { + "message": "malformed request: Invalid `boundary` for `multipart/form-data` request", + "type": "invalid_request_error", + "code": "malformed_request", + } + }) + ); + } + + #[tokio::test] + async fn batch_validation_preserves_the_gateway_error_message_contract() { + let body = "--empty\r\n\ + Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\r\n\ + Content-Type: audio/wav\r\n\r\n\ + bytes\r\n\ + --empty--\r\n"; + let response = build_router(state(), None) .oneshot( Request::builder() - .uri("/stt/capability") - .header("host", "gateway.lan:8080") + .method("POST") + .uri("/v1/audio/transcriptions") .header("authorization", "Bearer test-token") - .body(Body::empty()) + .header("content-type", "multipart/form-data; boundary=empty") + .body(Body::from(body)) .expect("request builds"), ) .await .expect("router answers"); - assert_eq!(authorized.status(), StatusCode::OK); - let body = axum::body::to_bytes(authorized.into_body(), usize::MAX) + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .expect("body reads"); - assert_eq!(&body[..], br#"{"gpu":false,"engine":false}"#); + let json: serde_json::Value = serde_json::from_slice(&body).expect("body is JSON"); + assert_eq!( + json, + serde_json::json!({ + "error": { + "message": "malformed request: missing multipart field model", + "type": "invalid_request_error", + "code": "malformed_request", + } + }) + ); } #[tokio::test] @@ -2489,6 +1886,10 @@ mod provisioning_tests { use axum::http::{Request, StatusCode}; use futures_util::future::BoxFuture; use gateway_config::{Config, ProfileName}; + #[cfg(feature = "stt")] + use gateway_stt::test_fixtures::{ + ScriptedDecoder, ScriptedModelFactory, begin_scripted_replacement, scripted_service, + }; use tokio_util::sync::CancellationToken; use tower::ServiceExt as _; @@ -2590,88 +1991,142 @@ mod provisioning_tests { worker.await.expect("the worker exits on shutdown"); } - /// A token fired after the spawn phase opens stops the switch before the - /// persist and the final routing-table swap. The canceller fires on the - /// `starting-models` phase opening; the start itself is a no-op (the - /// profile selects only a remote model), and the phase boundary after - /// the spawn keeps the cancelled switch from committing: the profile - /// is never recorded as active, and `loading` is left clear. + /// A featureless switch cancelled at the phase-independent spawn + /// rendezvous restores the prior routing and never publishes its target. + #[cfg(not(any(feature = "local", feature = "stt")))] #[tokio::test] - async fn a_token_fired_during_the_start_stops_the_persist_and_the_swap() { - let config = Config::from_toml_str( - "config-version = 2\n\ - [server]\nbind = \"127.0.0.1:0\"\napi_key = \"test-token\"\n\ - [[endpoint]]\nid = \"e\"\nprotocol = \"openai\"\n\ - base_url = \"http://127.0.0.1:9\"\napi_key = \"\"\n\ - [[model]]\nname = \"remote-model\"\ndescription = \"d\"\n\ - context = 8192\nupstream = \"u\"\nendpoints = [\"e\"]\n\ - [[profile]]\nname = \"main\"\nmodels = [\"remote-model\"]\n", - ) - .expect("config parses"); - let state = app_state(config, None); + async fn featureless_cancellation_stops_persistence_and_publication() { + let temp = tempfile::tempdir().expect("tempdir"); + let (mut state, state_path) = persisted_two_remote_profiles(&temp); + let park = Arc::new(crate::switch_park::PhasePark::at( + crate::switch_park::SwitchPhase::Spawn, + )); + state.park = Some(Arc::clone(&park)); let token = CancellationToken::new(); - let mut rx = state.hub.subscribe(); - let canceller = tokio::spawn({ - let token = token.clone(); - async move { - while let Ok(event) = rx.recv().await { - if matches!(event.state, shared_progress::EventState::Begun { .. }) - && event.label == "starting-models" - { - token.cancel(); - return; - } - } - } + let switch_state = state.clone(); + let switch_token = token.clone(); + let switch = tokio::spawn(async move { + let tree = switch_state.hub.operation(); + crate::run_switch_with_config( + switch_state, + ProfileName::parse("beta").expect("profile name"), + tree, + None, + || crate::StatePersistence::Write, + &switch_token, + ) + .await }); - let tree = state.hub.operation(); - let outcome = crate::run_switch_with_config( - state.clone(), - ProfileName::parse("main").expect("profile name"), - tree, - None, - || crate::StatePersistence::Write, - &token, - ) - .await; - canceller.await.expect("the canceller ran"); + + tokio::time::timeout(Duration::from_secs(10), park.entered()) + .await + .expect("featureless switch reaches spawn"); + token.cancel(); + park.release(); + let outcome = tokio::time::timeout(Duration::from_secs(10), switch) + .await + .expect("featureless cancellation settles") + .expect("switch task joins"); assert!( matches!( outcome, Err(crate::error::GatewayError::CommandCancelled(_)) ), - "the late cancellation stops the switch: {outcome:?}" - ); + "the late cancellation stops the switch: {outcome:?}" + ); + let live = state.live.read().await; + assert_eq!(live.profile_name.as_deref(), Some("alpha")); + assert!(live.routing.model("alpha-model").is_ok()); + assert!(live.routing.model("beta-model").is_err()); + assert_eq!( + std::fs::read_to_string(state_path).expect("read profile state"), + "active_profile = \"alpha\"\n" + ); + assert!( + live.loading.is_empty(), + "a cancelled switch leaves no model promised as loading" + ); + } + + /// The remote-only catalog the lock tests switch within: `alpha` and + /// `beta` each select one remote model on an endpoint nothing listens + /// on, and the harness state starts with `alpha` live. + fn two_remote_catalog() -> &'static str { + "config-version = 2\n\ + [server]\nbind = \"127.0.0.1:0\"\napi_key = \"test-token\"\n\ + [[endpoint]]\nid = \"e\"\nprotocol = \"openai\"\n\ + base_url = \"http://127.0.0.1:9\"\napi_key = \"\"\n\ + [[model]]\nname = \"alpha-model\"\ndescription = \"a\"\n\ + context = 8192\nupstream = \"a\"\nendpoints = [\"e\"]\n\ + [[model]]\nname = \"beta-model\"\ndescription = \"b\"\n\ + context = 8192\nupstream = \"b\"\nendpoints = [\"e\"]\n\ + [[profile]]\nname = \"alpha\"\nmodels = [\"alpha-model\"]\n\ + [[profile]]\nname = \"beta\"\nmodels = [\"beta-model\"]\n" + } + + fn two_remote_profiles() -> AppState { + let catalog = Config::from_toml_str(two_remote_catalog()).expect("config parses"); + let config = catalog + .select_profile(&ProfileName::parse("alpha").expect("profile name")) + .expect("alpha profile selects"); + app_state(config, None) + } + + fn persisted_two_remote_profiles(temp: &tempfile::TempDir) -> (AppState, std::path::PathBuf) { + let config_path = temp.path().join("gateway.toml"); + std::fs::write(&config_path, two_remote_catalog()).expect("write catalog"); + let state_path = gateway_config::profile_state_path(&config_path); + std::fs::write(&state_path, "active_profile = \"alpha\"\n").expect("write state"); + let config = Config::load( + &config_path, + &gateway_config::ProfileSelection::new(Some("alpha"), None), + ) + .expect("load alpha profile"); + let state = app_state( + config, + Some(crate::test_support::AdminPaths { + fixture_dir: temp.path().to_path_buf(), + active: "alpha".to_owned(), + config_path, + }), + ); + (state, state_path) + } + + #[cfg(not(any(feature = "local", feature = "stt")))] + #[tokio::test] + async fn featureless_profile_switch_commits_the_complete_target() { + let temp = tempfile::tempdir().expect("tempdir"); + let (state, state_path) = persisted_two_remote_profiles(&temp); + let token = CancellationToken::new(); + let tree = state.hub.operation(); + let outcome = tokio::time::timeout( + Duration::from_secs(10), + crate::run_switch_with_config( + state.clone(), + ProfileName::parse("beta").expect("profile name"), + tree, + None, + || crate::StatePersistence::Write, + &token, + ), + ) + .await + .expect("featureless switch settles") + .expect("featureless switch commits"); + + assert_eq!(outcome, "beta"); let live = state.live.read().await; - assert!( - live.profile_name.is_none(), - "the cancelled switch never committed the profile" - ); - assert!( - live.loading.is_empty(), - "a cancelled switch leaves no model promised as loading" + assert_eq!(live.profile_name.as_deref(), Some("beta")); + assert!(live.routing.model("alpha-model").is_err()); + assert!(live.routing.model("beta-model").is_ok()); + assert_eq!( + std::fs::read_to_string(state_path).expect("read profile state"), + "active_profile = \"beta\"\n" ); - } - - /// The remote-only catalog the lock tests switch within: `alpha` and - /// `beta` each select one remote model on an endpoint nothing listens - /// on, and the harness state starts with `alpha` live. - fn two_remote_profiles() -> AppState { - let config = Config::from_toml_str( - "config-version = 2\n\ - [server]\nbind = \"127.0.0.1:0\"\napi_key = \"test-token\"\n\ - [[endpoint]]\nid = \"e\"\nprotocol = \"openai\"\n\ - base_url = \"http://127.0.0.1:9\"\napi_key = \"\"\n\ - [[model]]\nname = \"alpha-model\"\ndescription = \"a\"\n\ - context = 8192\nupstream = \"a\"\nendpoints = [\"e\"]\n\ - [[model]]\nname = \"beta-model\"\ndescription = \"b\"\n\ - context = 8192\nupstream = \"b\"\nendpoints = [\"e\"]\n\ - [[profile]]\nname = \"alpha\"\nmodels = [\"alpha-model\"]\n\ - [[profile]]\nname = \"beta\"\nmodels = [\"beta-model\"]\n", - ) - .expect("config parses"); - app_state(config, None) + assert!(!token.is_cancelled()); + assert!(!state.shutdown.is_fired()); } /// Runs the switch to `profile` on its own task with no persistence. @@ -2748,6 +2203,530 @@ mod provisioning_tests { ); } + #[tokio::test] + async fn cancellation_at_each_switch_await_preserves_the_old_routing() { + for phase in [ + crate::switch_park::SwitchPhase::Download, + crate::switch_park::SwitchPhase::CutOver, + crate::switch_park::SwitchPhase::Spawn, + crate::switch_park::SwitchPhase::Commit, + ] { + let mut state = two_remote_profiles(); + let park = Arc::new(crate::switch_park::PhasePark::at(phase)); + state.park = Some(Arc::clone(&park)); + let token = CancellationToken::new(); + let switch = spawn_switch(&state, "beta", &token); + + tokio::time::timeout(Duration::from_secs(10), park.entered()) + .await + .unwrap_or_else(|_| panic!("switch did not reach {phase:?}")); + token.cancel(); + park.release(); + let outcome = tokio::time::timeout(Duration::from_secs(10), switch) + .await + .expect("cancelled switch settles") + .expect("switch task joins"); + + assert!( + matches!( + outcome, + Err(crate::error::GatewayError::CommandCancelled(_)) + ), + "{phase:?} cancellation is explicit: {outcome:?}" + ); + let live = state.live.read().await; + assert!( + live.routing.model("alpha-model").is_ok(), + "{phase:?} cancellation restores old routing" + ); + assert!( + live.routing.model("beta-model").is_err(), + "{phase:?} cancellation never publishes target routing" + ); + } + } + + #[tokio::test] + async fn indeterminate_staging_timeout_requests_shutdown_without_persisting() { + let temp = tempfile::tempdir().expect("tempdir"); + let (mut state, state_path) = persisted_two_remote_profiles(&temp); + state.switch_fault = Some(crate::switch_park::SwitchFault::StageIndeterminate); + let token = CancellationToken::new(); + let tree = state.hub.operation(); + + let error = tokio::time::timeout( + Duration::from_secs(10), + crate::run_switch_with_config( + state.clone(), + ProfileName::parse("beta").expect("profile name"), + tree, + None, + || crate::StatePersistence::Write, + &token, + ), + ) + .await + .expect("injected staging timeout settles") + .expect_err("indeterminate staging fails"); + + let chain = crate::config_write::error_chain(&error); + assert!(chain.contains("stage-profile-timeout")); + assert!(chain.contains("injected non-preemptible runtime startup timeout")); + assert_eq!( + std::fs::read_to_string(state_path).expect("read profile state"), + "active_profile = \"alpha\"\n" + ); + let live = state.live.read().await; + assert_eq!(live.profile_name.as_deref(), Some("alpha")); + assert!(live.routing.model("alpha-model").is_err()); + assert!(live.routing.model("beta-model").is_ok()); + assert!(token.is_cancelled()); + assert!(state.shutdown.is_fired()); + #[cfg(feature = "stt")] + assert!(!state.speech.status().ready()); + } + + #[cfg(feature = "stt")] + #[tokio::test] + async fn failed_speech_publication_is_indeterminate_after_persistence() { + let temp = tempfile::tempdir().expect("tempdir"); + let (mut state, state_path) = persisted_two_remote_profiles(&temp); + let park = Arc::new(crate::switch_park::PhasePark::at( + crate::switch_park::SwitchPhase::Publish, + )); + state.park = Some(Arc::clone(&park)); + let token = CancellationToken::new(); + let switch_state = state.clone(); + let switch_token = token.clone(); + let switch = tokio::spawn(async move { + let tree = switch_state.hub.operation(); + crate::run_switch_with_config( + switch_state, + ProfileName::parse("beta").expect("profile name"), + tree, + None, + || crate::StatePersistence::Write, + &switch_token, + ) + .await + }); + + tokio::time::timeout(Duration::from_secs(10), park.entered()) + .await + .expect("switch reaches speech publication"); + assert_eq!( + std::fs::read_to_string(&state_path).expect("read profile state"), + "active_profile = \"beta\"\n" + ); + state.speech.shutdown(); + park.release(); + let error = tokio::time::timeout(Duration::from_secs(10), switch) + .await + .expect("failed speech publication settles") + .expect("switch task joins") + .expect_err("invalidated speech publication is fatal"); + + let chain = crate::config_write::error_chain(&error); + assert!(chain.contains("publish-stt")); + assert!(chain.contains("invalidated")); + let live = state.live.read().await; + assert_eq!(live.profile_name.as_deref(), Some("alpha")); + assert!(live.routing.model("alpha-model").is_err()); + assert!(live.routing.model("beta-model").is_ok()); + assert!(!state.speech.status().ready()); + assert!(token.is_cancelled()); + assert!(state.shutdown.is_fired()); + } + + #[cfg(feature = "stt")] + #[tokio::test] + async fn determinate_commit_with_failed_speech_rollback_requests_shutdown() { + let old = ScriptedDecoder::new(); + let service = scripted_service(ScriptedModelFactory::new(old.clone()), 15, 500) + .expect("old speech starts"); + let mut state = two_remote_profiles(); + state.speech = service; + let next = ScriptedDecoder::new(); + let speech = begin_scripted_replacement( + &state.speech, + ScriptedModelFactory::new(next.clone()), + false, + Duration::from_secs(1), + ) + .expect("new speech stages"); + old.fail_next_construction("gateway rollback sentinel"); + + let temp = tempfile::tempdir().expect("tempdir"); + let target_path = temp.path().join("gateway.state.toml"); + std::fs::write(&target_path, "active_profile = \"alpha\"\n").expect("write state"); + let persistence = crate::profile_switch::PreparedPersistence::for_test( + target_path, + "active_profile = \"beta\"\n".to_owned(), + ) + .expect("prepare state"); + persistence.discard_temporaries(); + let name = ProfileName::parse("beta").expect("profile name"); + let tree = state.hub.operation(); + let target = crate::profile_switch::prepare_target_for_test(&state, &name, &tree, None) + .await + .expect("target prepares"); + let replacement = crate::profile_switch::RuntimeReplacement { + #[cfg(feature = "local")] + local: crate::local::LocalRuntime::empty(), + #[cfg(feature = "local")] + start_failures: Vec::new(), + speech, + }; + let token = CancellationToken::new(); + + let error = crate::profile_switch::commit_for_test( + &state, + name, + target, + replacement, + persistence, + token.clone(), + ) + .await + .expect_err("failed rollback makes a determinate persistence failure fatal"); + + assert!(crate::config_write::error_chain(&error).contains("gateway rollback sentinel")); + assert!( + token.is_cancelled(), + "fatal rollback cancels the command token" + ); + assert!( + state.shutdown.is_fired(), + "fatal rollback requests shutdown" + ); + assert!(next.worker_dropped(), "the staged worker is joined"); + assert!(!state.speech.status().ready()); + } + + #[cfg(feature = "stt")] + #[tokio::test] + async fn indeterminate_persistence_invalidates_staging_and_requests_shutdown() { + let state = two_remote_profiles(); + let next = ScriptedDecoder::new(); + let speech = begin_scripted_replacement( + &state.speech, + ScriptedModelFactory::new(next.clone()), + false, + Duration::from_secs(1), + ) + .expect("speech stages"); + let temp = tempfile::tempdir().expect("tempdir"); + let target_path = temp.path().join("gateway.state.toml"); + std::fs::write(&target_path, "active_profile = \"alpha\"\n").expect("write state"); + let persistence = crate::profile_switch::PreparedPersistence::for_test( + target_path.clone(), + "active_profile = \"beta\"\n".to_owned(), + ) + .expect("prepare state"); + std::fs::write(&target_path, "uncertain authoritative contents") + .expect("make persistence state indeterminate"); + persistence.discard_temporaries(); + let name = ProfileName::parse("beta").expect("profile name"); + let tree = state.hub.operation(); + let target = crate::profile_switch::prepare_target_for_test(&state, &name, &tree, None) + .await + .expect("target prepares"); + let replacement = crate::profile_switch::RuntimeReplacement { + #[cfg(feature = "local")] + local: crate::local::LocalRuntime::empty(), + #[cfg(feature = "local")] + start_failures: Vec::new(), + speech, + }; + let token = CancellationToken::new(); + + crate::profile_switch::commit_for_test( + &state, + name, + target, + replacement, + persistence, + token.clone(), + ) + .await + .expect_err("indeterminate persistence is fatal"); + assert!(token.is_cancelled()); + assert!(state.shutdown.is_fired()); + assert!(next.worker_dropped(), "invalidated staging is still joined"); + let live = state.live.read().await; + assert!(live.routing.model("alpha-model").is_ok()); + assert!(live.routing.model("beta-model").is_err()); + } + + #[cfg(feature = "stt")] + #[tokio::test] + #[expect( + clippy::too_many_lines, + reason = "the single linear scenario proves both readers stay blocked across the same persistence-to-publication boundary" + )] + async fn pending_readers_serialize_with_persistence_and_live_publication() { + let temp = tempfile::tempdir().expect("tempdir"); + let config_path = temp.path().join("gateway.toml"); + std::fs::write(&config_path, two_remote_catalog()).expect("write catalog"); + let state_path = gateway_config::profile_state_path(&config_path); + std::fs::write(&state_path, "active_profile = \"alpha\"\n").expect("write state"); + let config = Config::load( + &config_path, + &gateway_config::ProfileSelection::new(Some("alpha"), None), + ) + .expect("load alpha profile"); + let mut state = app_state( + config, + Some(crate::test_support::AdminPaths { + fixture_dir: temp.path().to_path_buf(), + active: "alpha".to_owned(), + config_path, + }), + ); + let decoder = ScriptedDecoder::new(); + let speech = begin_scripted_replacement( + &state.speech, + ScriptedModelFactory::new(decoder.clone()), + false, + Duration::from_secs(1), + ) + .expect("speech stages"); + let persistence = crate::profile_switch::PreparedPersistence::for_test( + state_path.clone(), + "active_profile = \"beta\"\n".to_owned(), + ) + .expect("prepare state"); + let name = ProfileName::parse("beta").expect("profile name"); + let tree = state.hub.operation(); + let target = crate::profile_switch::prepare_target_for_test(&state, &name, &tree, None) + .await + .expect("target prepares"); + let replacement = crate::profile_switch::RuntimeReplacement { + #[cfg(feature = "local")] + local: crate::local::LocalRuntime::empty(), + #[cfg(feature = "local")] + start_failures: Vec::new(), + speech, + }; + let park = Arc::new(crate::switch_park::PhasePark::at( + crate::switch_park::SwitchPhase::Publish, + )); + state.park = Some(Arc::clone(&park)); + let token = CancellationToken::new(); + let commit_state = state.clone(); + let commit_token = token.clone(); + let commit = tokio::spawn(async move { + crate::profile_switch::commit_for_test( + &commit_state, + name, + target, + replacement, + persistence, + commit_token, + ) + .await + }); + tokio::time::timeout(Duration::from_secs(1), park.entered()) + .await + .expect("commit reaches the publication boundary"); + assert_eq!( + std::fs::read_to_string(&state_path).expect("read committed state"), + "active_profile = \"beta\"\n" + ); + assert_eq!( + state.live.read().await.profile_name.as_deref(), + Some("alpha") + ); + + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + axum::http::HeaderValue::from_static("Bearer test-token"), + ); + let caller = crate::auth::Caller::new( + headers, + Some("127.0.0.1:50000".parse().expect("loopback address")), + ); + let reader_state = state.clone(); + let dirty_state = state.clone(); + let catalog_state = state.clone(); + let status_state = state.clone(); + let dirty_caller = caller.clone(); + let catalog_caller = caller.clone(); + let status_caller = caller.clone(); + let mut reader = tokio::spawn(async move { + crate::config_pending::admin_config_pending(axum::extract::State(reader_state), caller) + .await + }); + let mut dirty_reader = tokio::spawn(async move { + crate::config_pending::admin_config_dirty( + axum::extract::State(dirty_state), + dirty_caller, + ) + .await + }); + let mut catalog_reader = tokio::spawn(async move { + crate::list_models(axum::extract::State(catalog_state), catalog_caller).await + }); + let mut status_reader = tokio::spawn(async move { + crate::admin_status(axum::extract::State(status_state), status_caller).await + }); + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut reader) + .await + .is_err(), + "pending readers wait while disk and live state differ" + ); + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut dirty_reader) + .await + .is_err(), + "dirty readers wait while disk and live state differ" + ); + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut catalog_reader) + .await + .is_err(), + "model discovery waits while speech and profile publication differ" + ); + assert!( + tokio::time::timeout(Duration::from_millis(50), &mut status_reader) + .await + .is_err(), + "operational status waits while speech and profile publication differ" + ); + + token.cancel(); + park.release(); + commit + .await + .expect("commit task joins") + .expect("cancellation after persistence cannot split publication"); + let axum::Json(reply) = reader + .await + .expect("reader task joins") + .expect("pending read succeeds"); + let axum::Json(dirty) = dirty_reader + .await + .expect("dirty reader task joins") + .expect("dirty read succeeds"); + let axum::Json(catalog) = catalog_reader + .await + .expect("catalog reader task joins") + .expect("catalog read succeeds"); + let axum::Json(status) = status_reader + .await + .expect("status reader task joins") + .expect("status read succeeds"); + assert_eq!(reply["profile"]["active_profile"], "beta"); + assert_eq!(dirty["dirty"], false); + let catalog = serde_json::to_value(catalog).expect("catalog serializes"); + assert_eq!(catalog["data"][1]["id"], "scripted-interim"); + assert_eq!(status["profile"], "beta"); + assert_eq!( + status["speech"], + serde_json::json!({ + "configured": true, + "ready": true, + "gpu": false, + "generation": 1, + }) + ); + assert_eq!( + state.live.read().await.profile_name.as_deref(), + Some("beta") + ); + assert!(decoder.creation_thread().is_some()); + } + + #[cfg(feature = "stt")] + #[test] + fn non_preemptible_speech_startup_timeout_is_fatal() { + let old = ScriptedDecoder::new(); + let service = scripted_service(ScriptedModelFactory::new(old.clone()), 15, 500) + .expect("old speech starts"); + let next = ScriptedDecoder::new(); + let replacement_service = service.clone(); + let next_factory = ScriptedModelFactory::new(next.clone()); + let (result, ()) = next_factory + .with_construction_blocked( + Duration::from_secs(1), + Duration::from_secs(1), + |factory| { + begin_scripted_replacement( + &replacement_service, + factory, + false, + Duration::from_millis(20), + ) + }, + || (), + ) + .expect("next construction reaches the blocked scenario"); + let error = result.expect_err("parked native-equivalent startup times out"); + let crate::profile_switch::RuntimeStageFailure::Indeterminate(error) = + crate::profile_switch::classify_speech_stage_failure(error) + else { + panic!("non-preemptible speech timeout must be fatal"); + }; + let mut state = two_remote_profiles(); + state.speech = service; + let token = CancellationToken::new(); + + let _error = crate::profile_switch::request_fatal_shutdown( + &state, + &token, + "stage-profile-timeout", + error, + ); + + assert!(token.is_cancelled()); + assert!(state.shutdown.is_fired()); + assert!( + old.worker_dropped(), + "the old generation was joined before startup" + ); + assert!(!state.speech.status().ready()); + assert!( + next.wait_until_worker_dropped(Duration::from_secs(1)), + "abandoned startup worker exits after construction returns" + ); + } + + #[cfg(feature = "stt")] + #[test] + fn controlled_shutdown_invalidates_an_unpublished_replacement_token() { + let state = two_remote_profiles(); + let decoder = ScriptedDecoder::new(); + let replacement = begin_scripted_replacement( + &state.speech, + ScriptedModelFactory::new(decoder.clone()), + false, + Duration::from_secs(1), + ) + .expect("speech stages"); + let token = CancellationToken::new(); + + let _error = crate::profile_switch::request_fatal_shutdown( + &state, + &token, + "fatal-test", + crate::error::GatewayError::switch_failed( + "fatal-test", + std::io::Error::other("sentinel"), + ), + ); + let error = state + .speech + .commit_replacement(replacement) + .expect_err("shutdown invalidates the staged token"); + + assert!(error.to_string().contains("invalidated")); + assert!(token.is_cancelled()); + assert!(state.shutdown.is_fired()); + assert!(decoder.worker_dropped()); + } + /// A profile over one remote model on `backend` and one local model /// whose source is a real file but whose `llama-server` is a plain text /// file, so the artifact step succeeds and the spawn fails per model. @@ -3276,6 +3255,28 @@ mod progress_tests { ); } + #[tokio::test] + async fn a_subscriber_sees_when_the_complete_operation_detaches() { + let hub = Arc::new(ProgressHub::new()); + let response = progress_sse_response(&hub, ShutdownSignal::default()); + let mut frames = response.into_body().into_data_stream(); + let tree = hub.operation(); + let operation = tree.operation(); + let leaf = tree.register("loading-profile", 1.0); + leaf.complete(); + drop(tree); + + let events = read_until(&mut frames, |event| { + matches!(event.state, EventState::OperationFinished) + }) + .await; + assert_eq!( + events.last().map(|event| event.operation), + Some(operation), + "the terminal lifecycle event names the detached operation" + ); + } + #[tokio::test] async fn a_lagged_subscriber_drops_the_overflow_and_carries_on() { let hub = Arc::new(ProgressHub::new()); @@ -3324,20 +3325,24 @@ mod progress_tests { } } - #[tokio::test] - async fn the_stream_goes_quiet_when_the_tree_drops() { + #[tokio::test(start_paused = true)] + async fn a_tree_drop_reports_completion_then_the_stream_goes_quiet() { let hub = Arc::new(ProgressHub::new()); let response = progress_sse_response(&hub, ShutdownSignal::default()); let mut frames = response.into_body().into_data_stream(); let tree = hub.operation(); + let operation = tree.operation(); let _leaf = tree.register("download", 1.0); let events = read_events(&mut frames, 1).await; assert!(matches!(events[0].state, EventState::Begun { .. })); drop(tree); + let events = read_events(&mut frames, 1).await; + assert_eq!(events[0].operation, operation); + assert!(matches!(events[0].state, EventState::OperationFinished)); // The first heartbeat is 15 s out, so nothing may arrive inside this - // window: a detached tree emits no events and an idle hub is silent. + // window after completion: an idle hub is otherwise silent. assert!( tokio::time::timeout(Duration::from_millis(300), frames.next()) .await @@ -3574,7 +3579,7 @@ cache_dir = '{cache}' /// that happens past the wall, so any non-403 status proves /// admission. fn walled_requests() -> Vec<(Method, &'static str)> { - let mut requests = vec![ + let requests = vec![ (Method::GET, "/admin/config"), (Method::PUT, "/admin/config"), (Method::GET, "/admin/env"), @@ -3589,11 +3594,15 @@ cache_dir = '{cache}' (Method::POST, "/admin/reveal"), ]; #[cfg(feature = "local")] - requests.extend([ - (Method::GET, "/admin/chat-templates"), - (Method::GET, "/admin/orphans"), - (Method::GET, "/admin/model-info"), - ]); + let requests = { + let mut requests = requests; + requests.extend([ + (Method::GET, "/admin/chat-templates"), + (Method::GET, "/admin/orphans"), + (Method::GET, "/admin/model-info"), + ]); + requests + }; requests } diff --git a/crates/gateway/src/main.rs b/crates/gateway/src/main.rs index 279f30a2..480d7ae8 100644 --- a/crates/gateway/src/main.rs +++ b/crates/gateway/src/main.rs @@ -1,5 +1,5 @@ //! The `promptforge-gateway` binary: -//! `promptforge-gateway serve [config.toml] [--profile NAME] [--no-tray] [--login] [--print-url] [--browser]`. +//! `promptforge-gateway [--config PATH] [--profile NAME] [--no-tray] [--login] [--print-url] [--browser]`. //! //! This is a thin shell: it parses arguments into a typed [`ServeOptions`] and //! hands off to [`run_with_tray`], which owns the tokio runtime, provisioning, @@ -11,10 +11,11 @@ //! page (or prints its URL under `--print-url`) and exits. use std::ffi::OsString; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::process::ExitCode; use gateway::{ProfileName, ServeOptions, run, run_printing_url, run_with_tray}; +use gateway_logging::{LogConfig, LogRuntime}; use tracing_subscriber::Layer as _; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::util::SubscriberInitExt; @@ -25,11 +26,15 @@ use tracing_subscriber::util::SubscriberInitExt; const DEFAULT_LOG_FILTER: &str = "info,whisper_cpp=warn,hyper=warn,h2=warn,reqwest=warn,tower=warn"; const USAGE: &str = concat!( - "usage: promptforge-gateway serve [config.toml] [--profile NAME] [--no-tray] [--login] [--print-url] [--browser]\n", + "usage: promptforge-gateway [--config PATH] [--profile NAME] [--no-tray] [--login] [--print-url] [--browser]\n", + " promptforge-gateway diagnostics [--config PATH]\n", " promptforge-gateway --version\n", - "the config path may also be set with the PROMPTFORGE_GATEWAY_CONFIG environment variable\n", + "the config path may also be set with the PROMPTFORGE_GATEWAY_CONFIG environment variable;\n", + "--config wins over it\n", "with no config path, the gateway searches beside the executable, the current directory,\n", "and the profile's .promptforge directory, generating a default config on first run\n", + "diagnostics print a JSON report of the state dir, config, logs, and connection file;\n", + " never serves, rotates logs, or parses the config\n", "--no-tray run headless (Ctrl-C driven); for servers and CI\n", "--login the launch came from the OS autostart entry; never opens a browser\n", "--print-url print the Settings handoff URL once bound, then serve headless;\n", @@ -38,9 +43,12 @@ const USAGE: &str = concat!( " the installer's first run uses this", ); -#[expect( - unsafe_code, - reason = "the one-call DPI-awareness shim at process start; every other unsafe lives in the tray and registry modules" +#[cfg_attr( + windows, + expect( + unsafe_code, + reason = "the one-call DPI-awareness shim at process start; every other unsafe lives in the tray and registry modules" + ) )] fn main() -> ExitCode { // The process is PerMonitorV2 DPI-aware from the start: the tray menu's @@ -72,32 +80,41 @@ fn main() -> ExitCode { } }; - // Logging starts only for a serve launch: a `--version` or `--help` - // call must not rotate the running gateway's log out from under it. - init_logging(); + // The diagnostics report is not a boot: it runs before the handoff + // check and before logging starts, and never rotates a log, parses a + // config, or mutates the state directory. + if invocation.command == Command::Diagnostics { + print!( + "{}", + gateway::diagnostics_json(invocation.serve.config_path) + ); + return ExitCode::SUCCESS; + } // A second launch never boots a duplicate server: when a live gateway // owns the connection file, hand off to it and exit. This runs before - // any bind attempt; on the desktop it is also the `.desktop` launcher's - // relaunch behavior. + // logging starts and before any bind attempt - a handoff must not + // rotate the running gateway's log out from under it. On the desktop + // it is also the `.desktop` launcher's relaunch behavior. if let Some(url) = gateway::running_gateway_settings_url(&invocation.serve) { if invocation.print_url { println!("{url}"); } else if invocation.login { // A login-triggered start never opens a browser; the running // gateway leaves this launch nothing to do. - tracing::info!("a gateway is already running; the login-triggered launch exits"); - } else { - tracing::info!("a gateway is already running; opening its Settings page"); - if let Err(error) = open::that(&url) { - tracing::warn!( - "could not open the browser: {error}; the running gateway's Settings URL is {url}" - ); - } + } else if let Err(error) = open::that(&url) { + eprintln!( + "could not open the browser: {error}; the running gateway's Settings URL is {url}" + ); } return ExitCode::SUCCESS; } + // Logging starts only on the serving path: `--help`, `--version`, and + // a second-instance handoff must not rotate the running gateway's log + // out from under it. + let logging = init_logging(); + let result = if invocation.print_url { run_printing_url(&invocation.serve) } else if invocation.tray { @@ -105,77 +122,95 @@ fn main() -> ExitCode { } else { run(&invocation.serve) }; - match result { - Ok(()) => ExitCode::SUCCESS, + let exit = match result { + Ok(()) => { + if logging.is_some() { + tracing::info!("gateway exiting"); + } + ExitCode::SUCCESS + } Err(error) => { - print_error_chain(&error); + // A fatal error is logged once with its complete source chain; + // raw stderr is only the fallback when the logger never + // started. + if logging.is_some() { + log_error_chain(&error); + tracing::error!("gateway exiting after a fatal error"); + } else { + print_error_chain(&error); + } ExitCode::FAILURE } - } + }; + // The logger shuts down last, so a healthy sink drains the terminal + // outcome and every admitted record before exit. A stalled sink gets + // bounded loss accounting and cannot hold process exit forever. + if let Some(runtime) = logging + && let Err(error) = runtime.shutdown() + { + eprintln!("could not shut down the log worker: {error}"); + } + exit } -/// Installs the global subscriber: the filtered stream on stdout, plus the -/// same stream in `/logs/gateway.log`, where the state dir is the +/// Installs the global subscriber and starts the log pipeline: the filtered +/// stream on stdout, plus the same stream through the bounded queue into +/// `/logs/gateway.log`, where the state dir is the /// `.promptforge` directory the run directory's resolver already knows /// (it holds `gateway.toml`, `run/`, and `models/`). A log file that cannot -/// be opened warns on stdout and never stops the gateway. -fn init_logging() { +/// be opened warns on stdout and never stops the gateway. The returned +/// runtime must be shut down last. +fn init_logging() -> Option { let filter = || { tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new(DEFAULT_LOG_FILTER)) }; let stdout = tracing_subscriber::fmt::layer().with_filter(filter()); - let log_file = shared_sidecar::default_run_dir() - .and_then(|run_dir| run_dir.parent().map(Path::to_path_buf)) - .map(|state_dir| open_log_file(&state_dir)); - match log_file { - Some(Ok((path, file))) => { + let runtime = shared_sidecar::default_run_dir() + .and_then(|run_dir| run_dir.parent().map(PathBuf::from)) + .map(|state_dir| LogRuntime::start(LogConfig::new(state_dir))); + match runtime { + Some(Ok(runtime)) => { + let file_writer = runtime.writer(); let file_layer = tracing_subscriber::fmt::layer() .with_ansi(false) - .with_writer(std::sync::Mutex::new(file)) + .fmt_fields(file_writer.clone()) + .with_writer(file_writer) .with_filter(filter()); tracing_subscriber::registry() .with(stdout) .with(file_layer) .init(); - tracing::info!("logging to {}", path.display()); + tracing::info!("promptforge-gateway {} starting", env!("CARGO_PKG_VERSION")); + tracing::info!("logging to {}", runtime.path().display()); + Some(runtime) } Some(Err(error)) => { tracing_subscriber::registry().with(stdout).init(); - tracing::warn!("could not open the log file: {error}; logging to stdout only"); + tracing::warn!("could not start file logging: {error}; logging to stdout only"); + None } None => { tracing_subscriber::registry().with(stdout).init(); tracing::warn!("no user profile directory found; logging to stdout only"); + None } } } -/// Opens `/logs/gateway.log` fresh for this run, first rotating -/// an existing log to `gateway.log.1` and overwriting any older rotation, -/// so one previous run is kept and disk use stays bounded. -/// -/// # Errors -/// Returns the I/O failure from creating the directory, rotating the -/// existing log, or opening the fresh one. -fn open_log_file(state_dir: &Path) -> std::io::Result<(PathBuf, std::fs::File)> { - let logs = state_dir.join("logs"); - std::fs::create_dir_all(&logs)?; - let current = logs.join("gateway.log"); - let previous = logs.join("gateway.log.1"); - if current.is_file() { - // A rename cannot overwrite an existing destination on Windows, so - // the older rotation is removed first. - if previous.is_file() { - std::fs::remove_file(&previous)?; - } - std::fs::rename(¤t, &previous)?; +/// Log 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}"); + let mut source = error.source(); + while let Some(cause) = source { + tracing::error!(" caused by: {cause}"); + source = cause.source(); } - let file = std::fs::File::create(¤t)?; - Ok((current, file)) } -/// Print the error and its full `source()` chain to stderr. +/// Print 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}"); let mut source = error.source(); @@ -196,10 +231,22 @@ enum ParseError { Usage(String), } +/// What this launch does. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Command { + /// Serve (the default and only serving mode). + Serve, + /// Print the diagnostics report and exit. + Diagnostics, +} + /// The parsed invocation: the serve options plus how the main thread runs. #[derive(Debug)] struct Invocation { - /// What to serve. + /// What the launch does. + command: Command, + /// What to serve. Under [`Command::Diagnostics`] only the config path + /// is meaningful: the report names it. serve: ServeOptions, /// Whether the system tray occupies the main thread (default). /// `--no-tray` keeps the headless Ctrl-C loop for servers and CI. @@ -212,27 +259,23 @@ struct Invocation { print_url: bool, } -/// Parse `serve` arguments into a typed [`Invocation`]. +/// Parse the command line into a typed [`Invocation`]. /// -/// Uses `OsString` operands so non-UTF-8 config paths survive. The config -/// path (the one optional positional, falling back to -/// `PROMPTFORGE_GATEWAY_CONFIG`) stays optional: with neither set, the -/// gateway discovers or generates the boot config itself. `--profile NAME` -/// is validated into a [`ProfileName`] at parse time. +/// The bare invocation serves; there are no subcommands. Uses `OsString` +/// operands so non-UTF-8 config paths survive. The config path +/// (`--config PATH`, falling back to `PROMPTFORGE_GATEWAY_CONFIG`) stays +/// optional: with neither set, the gateway discovers or generates the +/// boot config itself. `--profile NAME` is validated into a +/// [`ProfileName`] at parse time. fn parse_args(args: impl IntoIterator) -> Result { let mut args = args.into_iter(); let _binary = args.next(); - match args.next() { - Some(command) if command == *"serve" => {} - Some(flag) if flag == *"--version" => return Err(ParseError::Version), - Some(other) => { - return Err(ParseError::Usage(format!( - "unknown command {}", - other.to_string_lossy() - ))); - } - None => return Err(ParseError::Usage("missing 'serve' subcommand".to_string())), + // `diagnostics` is the only subcommand and must come first. + let mut args = args.peekable(); + if args.peek().and_then(|arg| arg.to_str()) == Some("diagnostics") { + args.next(); + return parse_diagnostics_args(args); } let mut profile: Option = None; @@ -244,6 +287,15 @@ fn parse_args(args: impl IntoIterator) -> Result { + let path = args + .next() + .ok_or_else(|| ParseError::Usage("--config requires a path".to_string()))?; + if config_path.is_some() { + return Err(ParseError::Usage("--config accepts one path".to_string())); + } + config_path = Some(PathBuf::from(path)); + } Some("--profile") => { let name = args .next() @@ -260,17 +312,15 @@ fn parse_args(args: impl IntoIterator) -> Result print_url = true, Some("--browser") => browser = true, Some("-h" | "--help") => return Err(ParseError::Help), + Some("--version") => return Err(ParseError::Version), Some(other) if other.starts_with('-') => { return Err(ParseError::Usage(format!("unknown flag {other}"))); } _ => { - if config_path.is_some() { - return Err(ParseError::Usage(format!( - "unexpected argument {}", - arg.to_string_lossy() - ))); - } - config_path = Some(PathBuf::from(arg)); + return Err(ParseError::Usage(format!( + "unexpected argument {}", + arg.to_string_lossy() + ))); } } } @@ -279,6 +329,7 @@ fn parse_args(args: impl IntoIterator) -> Result) -> Result) -> Result { + let mut args = args; + let mut config_path: Option = None; + while let Some(arg) = args.next() { + match arg.to_str() { + Some("--config") => { + let path = args + .next() + .ok_or_else(|| ParseError::Usage("--config requires a path".to_string()))?; + if config_path.is_some() { + return Err(ParseError::Usage("--config accepts one path".to_string())); + } + config_path = Some(PathBuf::from(path)); + } + Some("-h" | "--help") => return Err(ParseError::Help), + _ => { + return Err(ParseError::Usage(format!( + "diagnostics accepts only --config PATH, got {}", + arg.to_string_lossy() + ))); + } + } + } + let config_path = + resolve_config_path(config_path, std::env::var_os("PROMPTFORGE_GATEWAY_CONFIG")); + Ok(Invocation { + command: Command::Diagnostics, + serve: ServeOptions::new(config_path, None), + tray: false, + login: false, + print_url: false, + }) +} + +/// Resolves the config path: the `--config` flag wins, then the /// `PROMPTFORGE_GATEWAY_CONFIG` environment variable - but only when it /// names an existing file. A stale env var warns and falls through to boot /// discovery: ambient state rots in ways a typed CLI path does not, and a -/// forgotten variable must not hard-fail a first-run boot. A CLI -/// positional is deliberate, so a missing file there stays an error +/// forgotten variable must not hard-fail a first-run boot. A `--config` +/// path is deliberate, so a missing file there stays an error /// downstream. /// /// Tests pass both sources explicitly and never touch the process @@ -317,6 +404,33 @@ fn resolve_config_path(cli: Option, env: Option) -> OptionExitCode") + .expect("the binary entry point exists"); + assert!( + source[..main].contains("#[cfg_attr(windows,expect(unsafe_code,reason="), + "the unsafe expectation must exist only with the Windows DPI shim" + ); + } + #[test] fn the_default_filter_keeps_gateway_info_and_quiets_whisper_cpp() { let filter = tracing_subscriber::EnvFilter::new(DEFAULT_LOG_FILTER); @@ -335,36 +449,6 @@ mod tests { ); } - #[test] - fn the_log_rotation_keeps_one_previous_run() { - let temp = tempfile::tempdir().expect("tempdir"); - std::fs::create_dir_all(temp.path().join("logs")).expect("logs dir"); - std::fs::write(temp.path().join("logs/gateway.log"), "first run").expect("seed log"); - - let (path, file) = open_log_file(temp.path()).expect("first rotation opens"); - drop(file); - assert_eq!(path, temp.path().join("logs/gateway.log")); - assert_eq!( - std::fs::read_to_string(temp.path().join("logs/gateway.log.1")).expect("rotated log"), - "first run", - "the previous run's log rotates to .1" - ); - assert_eq!( - std::fs::read_to_string(&path).expect("fresh log"), - "", - "the new run starts on a fresh file" - ); - - std::fs::write(&path, "second run").expect("write second run"); - let (_path, file) = open_log_file(temp.path()).expect("second rotation opens"); - drop(file); - assert_eq!( - std::fs::read_to_string(temp.path().join("logs/gateway.log.1")).expect("rotated log"), - "second run", - "a second rotation overwrites the older .1" - ); - } - fn args(items: &[&str]) -> Vec { std::iter::once("promptforge-gateway") .chain(items.iter().copied()) @@ -404,10 +488,66 @@ mod tests { assert_eq!(path, None, "the gateway discovers or generates the config"); } + #[test] + fn the_root_invocation_serves_with_discovery() { + let invocation = parse_args(args(&[])).expect("the bare invocation parses"); + assert_eq!( + invocation.serve.config_path, None, + "no --config defers to boot discovery" + ); + assert!(invocation.serve.profile.is_none()); + assert!(invocation.tray, "the tray is the default main loop"); + assert!(!invocation.login); + assert!(!invocation.print_url); + assert!( + !invocation.serve.browser, + "embedders and ordinary launches never open a browser" + ); + } + + #[test] + fn the_serve_verb_is_rejected() { + let error = parse_args(args(&["serve"])).unwrap_err(); + assert!( + matches!(error, ParseError::Usage(_)), + "the removed subcommand is a usage error, never an alias: {error:?}" + ); + } + + #[test] + fn a_positional_config_path_is_rejected() { + let error = parse_args(args(&["gateway.toml"])).unwrap_err(); + assert!( + matches!(error, ParseError::Usage(_)), + "the config path is --config PATH, never a positional: {error:?}" + ); + } + + #[test] + fn the_config_flag_sets_the_path() { + let invocation = parse_args(args(&["--config", "gateway.toml"])).expect("parse"); + assert_eq!( + invocation.serve.config_path, + Some(PathBuf::from("gateway.toml")) + ); + } + + #[test] + fn the_config_flag_requires_a_value() { + let error = parse_args(args(&["--config"])).unwrap_err(); + assert!(matches!(error, ParseError::Usage(_))); + } + + #[test] + fn the_config_flag_is_given_once() { + let error = parse_args(args(&["--config", "a.toml", "--config", "b.toml"])).unwrap_err(); + assert!(matches!(error, ParseError::Usage(_))); + } + #[test] fn parses_path_and_profile() { let invocation = - parse_args(args(&["serve", "gateway.toml", "--profile", "dev"])).expect("parse"); + parse_args(args(&["--config", "gateway.toml", "--profile", "dev"])).expect("parse"); assert_eq!( invocation.serve.profile.as_ref().map(ProfileName::as_str), Some("dev") @@ -420,30 +560,30 @@ mod tests { #[test] fn the_tray_is_default_and_login_is_off() { - let invocation = parse_args(args(&["serve", "gateway.toml"])).expect("parse"); + let invocation = parse_args(args(&["--config", "gateway.toml"])).expect("parse"); assert!(invocation.tray, "the tray is the default main loop"); assert!(!invocation.login); } #[test] fn no_tray_selects_the_headless_loop() { - let invocation = parse_args(args(&["serve", "--no-tray"])).expect("parse"); + let invocation = parse_args(args(&["--no-tray"])).expect("parse"); assert!(!invocation.tray); assert!(!invocation.login); } #[test] fn the_autostart_command_line_parses() { - // The Run-key entry is `"" serve --login`; a login launch must + // The Run-key entry is `"" --login`; a login launch must // never fail on its own command line. - let invocation = parse_args(args(&["serve", "--login"])).expect("parse"); + let invocation = parse_args(args(&["--login"])).expect("parse"); assert!(invocation.login); assert!(invocation.tray, "a login launch still shows the tray"); } #[test] fn print_url_parses_and_leaves_the_other_flags_alone() { - let invocation = parse_args(args(&["serve", "--print-url"])).expect("parse"); + let invocation = parse_args(args(&["--print-url"])).expect("parse"); assert!(invocation.print_url); assert!( invocation.tray, @@ -454,8 +594,13 @@ mod tests { #[test] fn print_url_combines_with_no_tray_and_a_config_path() { - let invocation = parse_args(args(&["serve", "gateway.toml", "--no-tray", "--print-url"])) - .expect("parse"); + let invocation = parse_args(args(&[ + "--config", + "gateway.toml", + "--no-tray", + "--print-url", + ])) + .expect("parse"); assert!(invocation.print_url); assert!(!invocation.tray); assert_eq!( @@ -466,7 +611,7 @@ mod tests { #[test] fn browser_parses_and_rides_the_serve_options() { - let invocation = parse_args(args(&["serve", "--browser"])).expect("parse"); + let invocation = parse_args(args(&["--browser"])).expect("parse"); assert!( invocation.serve.browser, "the flag reaches the spawn hook through ServeOptions" @@ -474,18 +619,9 @@ mod tests { assert!(invocation.tray, "the flag is independent of the run loop"); } - #[test] - fn browser_defaults_off() { - let invocation = parse_args(args(&["serve"])).expect("parse"); - assert!( - !invocation.serve.browser, - "embedders and ordinary launches never open a browser" - ); - } - #[test] fn login_wins_over_browser() { - let invocation = parse_args(args(&["serve", "--login", "--browser"])).expect("parse"); + let invocation = parse_args(args(&["--login", "--browser"])).expect("parse"); assert!( !invocation.serve.browser, "a login launch never opens a browser" @@ -494,38 +630,39 @@ mod tests { #[test] fn missing_profile_defers_to_environment_or_state() { - let invocation = parse_args(args(&["serve", "gateway.toml"])).expect("parse"); + let invocation = parse_args(args(&["--config", "gateway.toml"])).expect("parse"); assert!(invocation.serve.profile.is_none()); } #[test] fn invalid_profile_name_is_a_usage_error() { - let error = parse_args(args(&["serve", "gateway.toml", "--profile", ""])).unwrap_err(); + let error = parse_args(args(&["--config", "gateway.toml", "--profile", ""])).unwrap_err(); assert!(matches!(error, ParseError::Usage(_))); } #[test] fn rejects_traversal_profile_name() { - let error = - parse_args(args(&["serve", "gateway.toml", "--profile", "../escape"])).unwrap_err(); + let error = parse_args(args(&[ + "--config", + "gateway.toml", + "--profile", + "../escape", + ])) + .unwrap_err(); assert!(matches!(error, ParseError::Usage(_))); } #[test] - fn rejects_unknown_command() { + fn rejects_an_unknown_argument() { let error = parse_args(args(&["frobnicate"])).unwrap_err(); assert!(matches!(error, ParseError::Usage(_))); } - #[test] - fn requires_serve_subcommand() { - let error = parse_args(args(&[])).unwrap_err(); - assert!(matches!(error, ParseError::Usage(_))); - } - #[test] fn help_is_recognized() { - let error = parse_args(args(&["serve", "--help"])).unwrap_err(); + let error = parse_args(args(&["--help"])).unwrap_err(); + assert_eq!(error, ParseError::Help); + let error = parse_args(args(&["-h"])).unwrap_err(); assert_eq!(error, ParseError::Help); } @@ -537,15 +674,101 @@ mod tests { #[test] fn rejects_unknown_flag() { - let error = - parse_args(args(&["serve", "--profiles-dir", "x", "--profile", "dev"])).unwrap_err(); + let error = parse_args(args(&["--profiles-dir", "x", "--profile", "dev"])).unwrap_err(); assert!(matches!(error, ParseError::Usage(_))); } #[test] - fn rejects_a_second_positional() { - let error = - parse_args(args(&["serve", "a.toml", "b.toml", "--profile", "dev"])).unwrap_err(); - assert!(matches!(error, ParseError::Usage(_))); + fn diagnostics_is_the_only_subcommand() { + let invocation = parse_args(args(&["diagnostics"])).expect("parses"); + assert_eq!(invocation.command, Command::Diagnostics); + assert_eq!(invocation.serve.config_path, None); + let invocation = parse_args(args(&[])).expect("the bare invocation parses"); + assert_eq!(invocation.command, Command::Serve); + } + + #[test] + fn diagnostics_accepts_a_config_path() { + let invocation = + parse_args(args(&["diagnostics", "--config", "gateway.toml"])).expect("parses"); + assert_eq!(invocation.command, Command::Diagnostics); + assert_eq!( + invocation.serve.config_path, + Some(PathBuf::from("gateway.toml")) + ); + } + + #[test] + fn diagnostics_rejects_serving_flags() { + for rest in ["--no-tray", "--login", "--print-url", "--browser"] { + let error = parse_args(args(&["diagnostics", rest])).unwrap_err(); + assert!( + matches!(error, ParseError::Usage(_)), + "diagnostics rejects {rest}: {error:?}" + ); + } + } + + #[test] + fn diagnostics_after_a_flag_is_not_a_subcommand() { + let error = parse_args(args(&["--no-tray", "diagnostics"])).unwrap_err(); + assert!( + matches!(error, ParseError::Usage(_)), + "the subcommand must come first: {error:?}" + ); + } + + /// A fatal returned error is logged once with its complete source + /// chain, and the queue drains to disk before the process exits. + #[test] + fn a_fatal_error_is_logged_once_with_its_full_chain_then_drained() { + #[derive(Debug)] + struct Chain(&'static str, Option>); + + impl std::fmt::Display for Chain { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.0) + } + } + + impl std::error::Error for Chain { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.1 + .as_deref() + .map(|cause| cause as &dyn std::error::Error) + } + } + + let temp = tempfile::tempdir().expect("tempdir"); + let runtime = LogRuntime::start(LogConfig::new(temp.path().join("state"))) + .expect("start the log pipeline"); + let log_path = runtime.path().to_path_buf(); + let subscriber = tracing_subscriber::fmt() + .with_ansi(false) + .with_writer(runtime.writer()) + .finish(); + let error = Chain( + "serve the gateway", + Some(Box::new(Chain( + "bind 127.0.0.1:8081", + Some(Box::new(Chain("address already in use", None))), + ))), + ); + tracing::subscriber::with_default(subscriber, || log_error_chain(&error)); + // The logger shuts down last, which is what drains the chain. + runtime.shutdown().expect("the queue drains before exit"); + + let log = std::fs::read_to_string(&log_path).expect("read the log"); + for link in [ + "error: serve the gateway", + "caused by: bind 127.0.0.1:8081", + "caused by: address already in use", + ] { + assert_eq!( + log.matches(link).count(), + 1, + "each chain link lands exactly once: {link}\n{log}" + ); + } } } diff --git a/crates/gateway/src/model_info.rs b/crates/gateway/src/model_info.rs index 2b6f091f..21426abe 100644 --- a/crates/gateway/src/model_info.rs +++ b/crates/gateway/src/model_info.rs @@ -7,20 +7,77 @@ //! The parser itself lives in the local crate beside the blob cache, which //! owns GGUF domain knowledge. +#[cfg(feature = "local")] use std::path::PathBuf; +#[cfg(feature = "local")] use std::sync::Arc; +#[cfg(feature = "local")] use axum::Json; +#[cfg(feature = "local")] use axum::extract::rejection::QueryRejection; +#[cfg(feature = "local")] use axum::extract::{Query, State}; +#[cfg(feature = "local")] use serde::Deserialize; +use serde::Serialize; +#[cfg(feature = "local")] use crate::auth::Caller; +#[cfg(feature = "local")] use crate::error::GatewayError; +#[cfg(feature = "local")] use crate::local::{LocalError, gguf, resolve_cache_root}; +use crate::wire::ModelInfo; +#[cfg(feature = "local")] use crate::{AppState, check_auth}; +/// The model-list wire response, including routed and active speech models. +#[derive(Debug, Serialize)] +pub(crate) struct CatalogModelsResponse { + /// Always `"list"`. + pub(crate) object: &'static str, + /// Models currently accepting their respective request shape. + pub(crate) data: Vec, +} + +/// One routed inference model or active speech model. +#[derive(Debug, Serialize)] +#[serde(untagged)] +pub(crate) enum CatalogModelInfo { + /// Existing chat, embedding, or classifier metadata. + Inference(ModelInfo), + /// Generic transcription metadata. + #[cfg(feature = "stt")] + Speech(SpeechCatalogModelInfo), +} + +impl CatalogModelInfo { + pub(crate) fn inference(model: ModelInfo) -> Self { + Self::Inference(model) + } + + #[cfg(feature = "stt")] + pub(crate) fn speech(model: &gateway_stt::SpeechModelInfo) -> Self { + Self::Speech(SpeechCatalogModelInfo { + id: model.name().to_owned(), + object: "model", + kind: "transcription", + }) + } +} + +/// Speech metadata contains only fields meaningful to transcription clients. +#[cfg(feature = "stt")] +#[derive(Debug, Serialize)] +pub(crate) struct SpeechCatalogModelInfo { + id: String, + object: &'static str, + kind: &'static str, +} + /// Query parameters for `GET /admin/model-info`. +#[cfg(feature = "local")] #[derive(Debug, Deserialize)] pub(crate) struct ModelInfoQuery { /// Cache-relative path of the GGUF file to inspect. @@ -39,6 +96,7 @@ pub(crate) struct ModelInfoQuery { /// arbitrary file. A missing or escaping path maps to 400; a file that is /// missing or not a well-formed GGUF header maps to 422. The UI treats any /// failure as "layer count unknown" and falls back to a plain readout. +#[cfg(feature = "local")] pub(crate) async fn admin_model_info( State(state): State, query: Result, QueryRejection>, @@ -74,13 +132,20 @@ pub(crate) async fn admin_model_info( Ok(Json(info)) } -#[cfg(test)] +#[cfg(all(test, feature = "local"))] mod tests { + #![expect( + clippy::expect_used, + reason = "route fixtures fail with the named setup or transport invariant" + )] + use std::net::SocketAddr; use std::path::Path; use gateway_config::Config; + #[cfg(feature = "stt")] + use super::{CatalogModelInfo, CatalogModelsResponse}; use crate::test_support::serve; /// A profile rooting the artifact cache at `cache_dir`. @@ -235,4 +300,37 @@ cache_dir = '{cache_dir}' "a request with the wrong bearer token is refused" ); } + + #[cfg(feature = "stt")] + #[test] + fn speech_catalog_metadata_is_generic_transcription_metadata() { + use gateway_stt::test_fixtures::{ScriptedDecoder, ScriptedModelFactory, scripted_service}; + + let factory = + ScriptedModelFactory::new(ScriptedDecoder::new()).with_final(ScriptedDecoder::new()); + let service = scripted_service(factory, 15, 500).expect("scripted service starts"); + let data = service + .models() + .iter() + .map(CatalogModelInfo::speech) + .collect(); + let value = serde_json::to_value(CatalogModelsResponse { + object: "list", + data, + }) + .expect("catalog serializes"); + + assert_eq!( + value, + serde_json::json!({ + "object": "list", + "data": [ + {"id": "scripted-interim", "object": "model", "kind": "transcription"}, + {"id": "scripted-final", "object": "model", "kind": "transcription"}, + {"id": "realtime-transcribe", "object": "model", "kind": "transcription"}, + ], + }) + ); + service.shutdown(); + } } diff --git a/crates/gateway/src/profile_switch.rs b/crates/gateway/src/profile_switch.rs new file mode 100644 index 00000000..0e04ca19 --- /dev/null +++ b/crates/gateway/src/profile_switch.rs @@ -0,0 +1,1997 @@ +//! Private profile-switch transaction. +//! +//! Prepared, cutover, staged, committed, rolled-back, indeterminate, and +//! terminal values own each phase's resources and legal transitions. + +use std::collections::BTreeSet; +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, LazyLock, OnceLock}; + +use gateway_config::{Config, ProfileName}; +use rand::Rng as _; +use shared_progress::{ProgressHandle, ProgressTree}; +use tokio_util::sync::CancellationToken; + +use crate::AppState; +use crate::error::GatewayError; +#[cfg(feature = "local")] +use crate::local::LocalRuntime; +use crate::routing::Routing; +#[cfg(feature = "stt")] +use gateway_stt::{SpeechReplacement, SpeechService}; +#[cfg(feature = "web-search")] +use gateway_web_search::WebSearchState; + +const PREPARED_CREATE_ATTEMPTS: u64 = 16; +/// Shared deadline for target staging and prior-runtime reconstruction. +pub(super) const STAGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); +static PERSISTENCE_NAMES: LazyLock u128>> = + LazyLock::new(|| ProcessPreparationNames::new(std::process::id(), random_persistence_nonce)); + +/// Error message when a configuration declaring `[[local_model]]` reaches a +/// build compiled without the `local` feature. +#[cfg(not(feature = "local"))] +pub(crate) const LOCAL_MODELS_UNSUPPORTED: &str = + "configuration declares [[local_model]] but this build lacks the `local` feature"; + +/// Error when STT reaches a gateway build without the heavy runtime. +#[cfg(not(feature = "stt"))] +pub(crate) const STT_RUNTIME_UNAVAILABLE: &str = + "the active profile selects [[stt_model]] but this build lacks the `stt` feature"; + +/// How a successful switch commits its active-profile state. +pub(crate) enum StatePersistence { + /// The selection already matches persisted state. + None, + /// Atomically replace real state while preserving any pending shadow. + Write, + /// Promote the shadows an Apply captured: each capture's contents land in + /// its real file, and the shadow is deleted only when it still holds + /// those contents, so a save that raced the apply stays pending. + Promote(Vec), +} + +struct ProcessPreparationNames { + pid: u32, + nonce: OnceLock, + sequence: AtomicU64, + random_nonce: N, +} + +impl u128> ProcessPreparationNames { + fn new(pid: u32, random_nonce: N) -> Self { + Self { + pid, + nonce: OnceLock::new(), + sequence: AtomicU64::new(0), + random_nonce, + } + } + + fn nonce(&self) -> u128 { + *self.nonce.get_or_init(|| (self.random_nonce)()) + } + + fn next_sequence(&self) -> u64 { + self.sequence.fetch_add(1, Ordering::Relaxed) + } +} + +/// One fully written and synced temporary file awaiting atomic replacement. +#[derive(Debug)] +struct PreparedFile { + target: PathBuf, + temporary: Option, + original: Option>, + contents: Vec, +} + +impl PreparedFile { + fn prepare(target: PathBuf, contents: String) -> Result { + Self::prepare_with_name_source(target, contents, &PERSISTENCE_NAMES) + } + + fn prepare_with_name_source u128>( + target: PathBuf, + contents: String, + names: &ProcessPreparationNames, + ) -> Result { + Self::prepare_with_names(target, contents, names.pid, names.nonce(), || { + names.next_sequence() + }) + } + + fn prepare_with_names( + target: PathBuf, + contents: String, + pid: u32, + nonce: u128, + mut next_sequence: impl FnMut() -> u64, + ) -> Result { + let original = match std::fs::read(&target) { + Ok(contents) => Some(contents), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(GatewayError::ConfigWriteIo(Box::new(error))), + }; + let (mut file, temporary) = create_prepared(&target, pid, nonce, &mut next_sequence) + .map_err(|error| GatewayError::ConfigWriteIo(Box::new(error)))?; + if let Err(error) = file + .write_all(contents.as_bytes()) + .and_then(|()| file.sync_all()) + { + drop(file); + let _ = std::fs::remove_file(&temporary); + return Err(GatewayError::ConfigWriteIo(Box::new(error))); + } + Ok(Self { + target, + temporary: Some(temporary), + original, + contents: contents.into_bytes(), + }) + } + + fn commit(&mut self) -> Result<(), std::io::Error> { + let temporary = self.temporary.as_ref().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "prepared persistence was already committed", + ) + })?; + std::fs::rename(temporary, &self.target)?; + self.temporary = None; + Ok(()) + } + + fn still_original(&self) -> bool { + match (&self.original, std::fs::read(&self.target)) { + (Some(original), Ok(current)) => ¤t == original, + (None, Err(error)) => error.kind() == std::io::ErrorKind::NotFound, + _ => false, + } + } + + fn has_committed_contents(&self) -> bool { + std::fs::read(&self.target).is_ok_and(|current| current == self.contents) + } + + fn target(&self) -> &Path { + &self.target + } + + #[cfg(test)] + fn discard_temporary(&self) { + let temporary = self + .temporary + .as_ref() + .expect("uncommitted preparation owns a temporary"); + std::fs::remove_file(temporary).expect("prepared temporary exists"); + } +} + +impl Drop for PreparedFile { + fn drop(&mut self) { + if let Some(temporary) = &self.temporary { + let _ = std::fs::remove_file(temporary); + } + } +} + +fn random_persistence_nonce() -> u128 { + rand::rng().random() +} + +#[derive(Debug)] +struct PreparedCreateExhausted { + target: PathBuf, + attempts: u64, + last_candidate: PathBuf, + source: std::io::Error, +} + +impl std::fmt::Display for PreparedCreateExhausted { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + formatter, + "failed to prepare {} after {} create_new attempts; last candidate {}", + self.target.display(), + self.attempts, + self.last_candidate.display() + ) + } +} + +impl std::error::Error for PreparedCreateExhausted { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.source) + } +} + +fn create_prepared( + target: &Path, + pid: u32, + nonce: u128, + next_sequence: &mut impl FnMut() -> u64, +) -> Result<(std::fs::File, PathBuf), std::io::Error> { + let mut last_collision = None; + for _ in 0..PREPARED_CREATE_ATTEMPTS { + let temporary = persistence_temporary(target, pid, nonce, next_sequence()); + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + { + Ok(file) => return Ok((file, temporary)), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + last_collision = Some((temporary, error)); + } + Err(error) => return Err(error), + } + } + let Some((last_candidate, source)) = last_collision else { + return Err(std::io::Error::other( + "prepared persistence retry budget must be nonzero", + )); + }; + Err(std::io::Error::new( + source.kind(), + PreparedCreateExhausted { + target: target.to_path_buf(), + attempts: PREPARED_CREATE_ATTEMPTS, + last_candidate, + source, + }, + )) +} + +fn persistence_temporary(target: &Path, pid: u32, nonce: u128, sequence: u64) -> PathBuf { + let mut name = target + .file_name() + .map_or_else(|| "profile".into(), std::ffi::OsStr::to_os_string); + name.push(format!(".prepared-{pid}-{nonce:032x}-{sequence}")); + target.with_file_name(name) +} + +#[expect( + clippy::unnecessary_wraps, + reason = "the cross-platform contract reports Unix directory sync failures; unsupported platforms are a no-op" +)] +fn sync_parent(path: &Path) -> Result<(), std::io::Error> { + #[cfg(unix)] + { + std::fs::File::open(path.parent().unwrap_or_else(|| Path::new(".")))?.sync_all() + } + #[cfg(not(unix))] + { + let _ = path; + Ok(()) + } +} + +/// Synced profile files and shadow captures awaiting terminal commit. +pub(super) struct PreparedPersistence { + files: Vec, + captures: Vec, +} + +/// Whether failed persistence left every authoritative file unchanged. +pub(super) enum PersistenceCommitError { + /// Every authoritative file still has its original contents. + Determinate(GatewayError), + /// At least one authoritative file may contain committed contents. + Indeterminate(GatewayError), +} + +impl PreparedPersistence { + async fn prepare( + state: &AppState, + name: &ProfileName, + persistence: StatePersistence, + ) -> Result { + let mut plans = Vec::new(); + let mut captures = Vec::new(); + match persistence { + StatePersistence::None => {} + StatePersistence::Write => { + if let Some(config) = state.config.as_ref() { + let contents = gateway_config::ProfileState::new(name) + .to_toml_string() + .map_err(crate::config_write::config_write_error)?; + plans.push((gateway_config::profile_state_path(&config.path), contents)); + } + } + StatePersistence::Promote(selected) => { + plans.extend( + selected + .iter() + .map(|capture| (capture.real_path.clone(), capture.contents.clone())), + ); + captures = selected; + } + } + let files = tokio::task::spawn_blocking(move || { + plans + .into_iter() + .map(|(target, contents)| PreparedFile::prepare(target, contents)) + .collect::, _>>() + }) + .await + .map_err(|join| GatewayError::ConfigWriteIo(Box::new(join)))??; + Ok(Self { files, captures }) + } + + /// Atomically replaces each target and retires matching shadows. + pub(super) async fn commit(self) -> Result<(), PersistenceCommitError> { + tokio::task::spawn_blocking(move || self.commit_blocking()) + .await + .map_err(|join| { + PersistenceCommitError::Indeterminate(GatewayError::ConfigWriteIo(Box::new(join))) + })? + } + + fn commit_blocking(mut self) -> Result<(), PersistenceCommitError> { + for file in &mut self.files { + if let Err(error) = file.commit() { + let error = GatewayError::ConfigWriteIo(Box::new(error)); + return if self.files.iter().all(PreparedFile::still_original) { + Err(PersistenceCommitError::Determinate(error)) + } else { + Err(PersistenceCommitError::Indeterminate(error)) + }; + } + } + for file in &self.files { + if !file.has_committed_contents() { + return Err(PersistenceCommitError::Indeterminate( + GatewayError::ConfigWriteIo(Box::new(std::io::Error::other( + "profile persistence could not verify committed contents", + ))), + )); + } + sync_parent(file.target()).map_err(|error| { + PersistenceCommitError::Indeterminate(GatewayError::ConfigWriteIo(Box::new(error))) + })?; + } + for capture in &self.captures { + let shadow = gateway_config::shadow_path(&capture.real_path); + match std::fs::read_to_string(&shadow) { + Ok(current) if current == capture.contents => { + if let Err(error) = std::fs::remove_file(&shadow) + && error.kind() != std::io::ErrorKind::NotFound + { + return Err(PersistenceCommitError::Indeterminate( + GatewayError::ConfigWriteIo(Box::new(error)), + )); + } + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(PersistenceCommitError::Indeterminate( + GatewayError::ConfigWriteIo(Box::new(error)), + )); + } + } + } + Ok(()) + } + + #[cfg(test)] + /// Prepares one persistence target for a terminal-commit test. + pub(super) fn for_test(target: PathBuf, contents: String) -> Result { + Ok(Self { + files: vec![PreparedFile::prepare(target, contents)?], + captures: Vec::new(), + }) + } + + #[cfg(test)] + /// Removes every owned temporary to force a commit failure. + pub(super) fn discard_temporaries(&self) { + for file in &self.files { + file.discard_temporary(); + } + } +} + +/// Everything target preparation resolves for the later phases. +pub(super) struct SwitchTarget { + /// The selected target configuration. + pub(super) config: Config, + /// Remote routing published at cutover and extended at terminal commit. + pub(super) remote_routing: Routing, + #[cfg(feature = "web-search")] + /// Web-search state published at terminal commit. + pub(super) web_search: Option>, + /// Model names admitted by the selected profile. + pub(super) allowlist: Option>, + loading: BTreeSet, + #[cfg(feature = "stt")] + speech: gateway_stt::PreparedSpeech, +} + +/// Target data that remains after the prepared speech artifact enters staging. +pub(super) struct StagedTarget { + config: Config, + remote_routing: Routing, + #[cfg(feature = "web-search")] + web_search: Option>, + allowlist: Option>, +} + +#[derive(Debug, Clone, Copy)] +struct StopSet { + #[cfg(feature = "local")] + local: bool, + #[cfg(feature = "stt")] + stt: bool, +} + +impl StopSet { + fn is_empty(self) -> bool { + let any = false; + #[cfg(feature = "local")] + let any = any || self.local; + #[cfg(feature = "stt")] + let any = any || self.stt; + !any + } +} + +/// Live runtime state captured immediately before cutover. +pub(super) struct PriorRuntimeSnapshot { + routing: Arc, + routing_was_empty: bool, + config: Arc, + #[cfg(feature = "web-search")] + web_search: Option>, + profile_name: Option, + model_allowlist: Option>, + loading: BTreeSet, + #[cfg(feature = "local")] + restart_local: bool, +} + +/// A transaction whose target and persistence are ready but whose live-state +/// cutover has not happened. +pub(super) struct PreparedPhase { + state: AppState, + name: ProfileName, + tree: ProgressTree, + target: SwitchTarget, + stop: StopSet, + persistence: PreparedPersistence, + token: CancellationToken, + download_after_cutover: bool, +} + +/// A transaction whose interim live-state cutover has happened. +pub(super) struct CutoverPhase { + state: AppState, + name: ProfileName, + tree: ProgressTree, + target: SwitchTarget, + persistence: PreparedPersistence, + prior: PriorRuntimeSnapshot, + token: CancellationToken, +} + +/// Cutover ownership after the prepared speech artifact has entered staging. +struct CutoverOwner { + state: AppState, + name: ProfileName, + target: StagedTarget, + persistence: PreparedPersistence, + prior: PriorRuntimeSnapshot, + token: CancellationToken, +} + +/// A transaction whose target runtimes are staged but not persisted or +/// published. +struct StagedPhase { + state: AppState, + name: ProfileName, + target: StagedTarget, + replacement: RuntimeReplacement, + persistence: PreparedPersistence, + prior: PriorRuntimeSnapshot, + token: CancellationToken, +} + +/// Staged ownership after persistence has been consumed. +struct CommitTail { + state: AppState, + name: ProfileName, + target: StagedTarget, + replacement: RuntimeReplacement, + prior: PriorRuntimeSnapshot, + token: CancellationToken, +} + +/// Persisted ownership awaiting atomic runtime and live-state publication. +struct PublicationPhase { + state: AppState, + name: ProfileName, + target: StagedTarget, + replacement: RuntimeReplacement, + #[cfg(feature = "stt")] + token: CancellationToken, + routing: Routing, +} + +/// A transaction that atomically persisted and published its target. +#[derive(Debug)] +struct CommittedPhase { + report: StartReport, +} + +/// A transaction that reconstructed and republished its prior runtime. +#[derive(Debug)] +struct RolledBackPhase { + error: GatewayError, +} + +/// A transaction whose runtime or persistence could not be proven and which +/// requested controlled shutdown. +#[derive(Debug)] +struct IndeterminatePhase { + error: GatewayError, +} + +/// The sole terminal owner returned by every post-preparation branch. +#[derive(Debug)] +enum TerminalPhase { + Committed(CommittedPhase), + RolledBack(RolledBackPhase), + Indeterminate(IndeterminatePhase), +} + +impl TerminalPhase { + fn finish(self) -> Result { + match self { + Self::Committed(phase) => Ok(phase.report), + Self::RolledBack(phase) => Err(phase.error), + Self::Indeterminate(phase) => Err(phase.error), + } + } +} + +/// What committed staging reported for local model startup. +#[derive(Debug)] +pub(super) struct StartReport { + #[cfg(feature = "local")] + loaded: Vec, + #[cfg(feature = "local")] + failed: Vec, +} + +/// The runtimes phase 4 started, swapped into live state only at commit. +pub(super) struct RuntimeReplacement { + #[cfg(feature = "local")] + pub(super) local: LocalRuntime, + #[cfg(feature = "local")] + pub(super) start_failures: Vec, + #[cfg(feature = "stt")] + pub(super) speech: SpeechReplacement, +} + +#[derive(Debug)] +#[cfg_attr( + not(any(feature = "local", feature = "stt")), + expect( + dead_code, + reason = "the featureless stage stub cannot produce either runtime failure classification" + ) +)] +pub(super) enum RuntimeStageFailure { + Determinate(GatewayError), + Indeterminate(GatewayError), +} + +impl PreparedPhase { + /// Consumes the prepared phase and produces the only value that can enter + /// runtime staging. + async fn cut_over(self) -> Result { + let prior = capture_runtime_snapshot(&self.state).await; + if let Err(error) = cut_over( + &self.state, + &self.target, + &self.tree, + self.stop, + &self.token, + ) + .await + { + return Err(self.roll_back(prior, error).await); + } + let cutover = CutoverPhase { + state: self.state, + name: self.name, + tree: self.tree, + target: self.target, + persistence: self.persistence, + prior, + token: self.token, + }; + if self.download_after_cutover { + #[cfg(test)] + cutover + .state + .park_at(crate::switch_park::SwitchPhase::Download) + .await; + match download_artifacts(&cutover.target, &cutover.tree, &cutover.token).await { + Ok(()) => {} + Err(error) => return Err(cutover.roll_back(error).await), + } + } + Ok(cutover) + } + + async fn roll_back(self, prior: PriorRuntimeSnapshot, failure: GatewayError) -> TerminalPhase { + match restore_runtime_snapshot(&self.state, prior).await { + Ok(()) => TerminalPhase::RolledBack(RolledBackPhase { error: failure }), + Err(rollback) => self.into_indeterminate("rollback-profile", rollback), + } + } + + fn into_indeterminate(self, phase: &'static str, failure: GatewayError) -> TerminalPhase { + TerminalPhase::Indeterminate(IndeterminatePhase { + error: request_fatal_shutdown(&self.state, &self.token, phase, failure), + }) + } +} + +impl CutoverPhase { + async fn stage(self) -> Result { + if self.token.is_cancelled() { + let error = switch_cancelled(&self.name); + return Err(self.roll_back(error).await); + } + #[cfg(test)] + { + self.state + .park_at(crate::switch_park::SwitchPhase::Spawn) + .await; + } + let Some(deadline) = std::time::Instant::now().checked_add(STAGE_TIMEOUT) else { + let error = GatewayError::switch_failed( + "stage-profile-deadline", + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "profile staging deadline could not be represented", + ), + ); + return Err(self.roll_back(error).await); + }; + let CutoverPhase { + state, + name, + tree, + target, + persistence, + prior, + token, + } = self; + let SwitchTarget { + config, + remote_routing, + #[cfg(feature = "web-search")] + web_search, + allowlist, + loading: _, + #[cfg(feature = "stt")] + speech: prepared_speech, + } = target; + let owner = CutoverOwner { + state, + name, + target: StagedTarget { + config, + remote_routing, + #[cfg(feature = "web-search")] + web_search, + allowlist, + }, + persistence, + prior, + token, + }; + #[cfg(test)] + if owner + .state + .has_switch_fault(crate::switch_park::SwitchFault::StageIndeterminate) + { + return Err(owner.into_indeterminate( + "stage-profile-timeout", + GatewayError::switch_failed( + "start-runtime-timeout", + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "injected non-preemptible runtime startup timeout", + ), + ), + )); + } + let replacement = match spawn_runtimes( + &owner.target.config, + #[cfg(feature = "stt")] + owner.state.speech.clone(), + #[cfg(feature = "stt")] + prepared_speech, + &tree, + &owner.token, + deadline, + ) + .await + { + Ok(replacement) => replacement, + Err(RuntimeStageFailure::Determinate(error)) => { + return Err(owner.roll_back(error).await); + } + Err(RuntimeStageFailure::Indeterminate(error)) => { + return Err(owner.into_indeterminate("stage-profile-timeout", error)); + } + }; + let staged = owner.into_staged(replacement); + if staged.token.is_cancelled() { + return Err(staged.roll_back_after_stage(switch_cancelled).await); + } + Ok(staged) + } + + async fn roll_back(self, failure: GatewayError) -> TerminalPhase { + match restore_runtime_snapshot(&self.state, self.prior).await { + Ok(()) => TerminalPhase::RolledBack(RolledBackPhase { error: failure }), + Err(rollback) => TerminalPhase::Indeterminate(IndeterminatePhase { + error: request_fatal_shutdown( + &self.state, + &self.token, + "rollback-profile", + rollback, + ), + }), + } + } +} + +impl CutoverOwner { + fn into_staged(self, replacement: RuntimeReplacement) -> StagedPhase { + StagedPhase { + state: self.state, + name: self.name, + target: self.target, + replacement, + persistence: self.persistence, + prior: self.prior, + token: self.token, + } + } + + async fn roll_back(self, failure: GatewayError) -> TerminalPhase { + match restore_runtime_snapshot(&self.state, self.prior).await { + Ok(()) => TerminalPhase::RolledBack(RolledBackPhase { error: failure }), + Err(rollback) => TerminalPhase::Indeterminate(IndeterminatePhase { + error: request_fatal_shutdown( + &self.state, + &self.token, + "rollback-profile", + rollback, + ), + }), + } + } + + fn into_indeterminate(self, phase: &'static str, failure: GatewayError) -> TerminalPhase { + TerminalPhase::Indeterminate(IndeterminatePhase { + error: request_fatal_shutdown(&self.state, &self.token, phase, failure), + }) + } +} + +impl StagedPhase { + async fn commit(self) -> TerminalPhase { + let state = self.state.clone(); + let _switch = state.switch.lock().await; + #[cfg(test)] + { + state.park_at(crate::switch_park::SwitchPhase::Commit).await; + } + #[cfg(feature = "local")] + let routing = match self + .target + .remote_routing + .clone() + .merge(self.replacement.local.models().iter().cloned()) + { + Ok(routing) => routing, + Err(error) => { + let failure = GatewayError::switch_failed("merge-routing", error); + return self.into_rollback(failure).finish().await; + } + }; + #[cfg(not(feature = "local"))] + let routing = self.target.remote_routing.clone(); + if self.token.is_cancelled() { + return self + .into_rollback(GatewayError::CommandCancelled("profile switch".to_owned())) + .finish() + .await; + } + let publication_state = state.clone(); + let _publication = tokio::select! { + biased; + () = self.token.cancelled() => { + return self + .into_rollback(GatewayError::CommandCancelled( + "profile switch".to_owned(), + )) + .finish() + .await; + } + guard = publication_state.apply.lock() => guard, + }; + if self.token.is_cancelled() { + return self + .into_rollback(GatewayError::CommandCancelled("profile switch".to_owned())) + .finish() + .await; + } + let StagedPhase { + state, + name, + target, + replacement, + persistence, + prior, + token, + } = self; + let tail = CommitTail { + state, + name, + target, + replacement, + prior, + token, + }; + match persistence.commit().await { + Ok(()) => {} + Err(PersistenceCommitError::Determinate(error)) => { + return tail.into_rollback(error).finish().await; + } + Err(PersistenceCommitError::Indeterminate(error)) => { + return tail.into_indeterminate("persist-profile-indeterminate", error); + } + } + let publication = tail.into_publication(routing); + #[cfg(test)] + { + publication + .state + .park_at(crate::switch_park::SwitchPhase::Publish) + .await; + } + publication.publish().await + } + + async fn roll_back_after_stage( + self, + cancellation: impl FnOnce(&ProfileName) -> GatewayError, + ) -> TerminalPhase { + let failure = cancellation(&self.name); + self.into_rollback(failure).finish().await + } + + fn into_rollback(self, failure: GatewayError) -> RollbackOwner { + let runtime_rollback = rollback_runtime(&self.state, self.replacement); + RollbackOwner { + state: self.state, + prior: self.prior, + token: self.token, + failure, + runtime_rollback, + } + } +} + +impl CommitTail { + fn into_rollback(self, failure: GatewayError) -> RollbackOwner { + let runtime_rollback = rollback_runtime(&self.state, self.replacement); + RollbackOwner { + state: self.state, + prior: self.prior, + token: self.token, + failure, + runtime_rollback, + } + } + + fn into_indeterminate(self, phase: &'static str, failure: GatewayError) -> TerminalPhase { + TerminalPhase::Indeterminate(IndeterminatePhase { + error: request_fatal_shutdown(&self.state, &self.token, phase, failure), + }) + } + + fn into_publication(self, routing: Routing) -> PublicationPhase { + PublicationPhase { + state: self.state, + name: self.name, + target: self.target, + replacement: self.replacement, + #[cfg(feature = "stt")] + token: self.token, + routing, + } + } +} + +impl PublicationPhase { + async fn publish(self) -> TerminalPhase { + let report = start_report(&self.replacement); + let PublicationPhase { + state, + name, + target, + #[cfg(any(feature = "local", feature = "stt"))] + replacement, + #[cfg(not(any(feature = "local", feature = "stt")))] + replacement: _, + #[cfg(feature = "stt")] + token, + routing, + } = self; + #[cfg(feature = "stt")] + if let Err(error) = state.speech.commit_replacement(replacement.speech) { + return TerminalPhase::Indeterminate(IndeterminatePhase { + error: request_fatal_shutdown( + &state, + &token, + "publish-stt", + GatewayError::switch_failed("publish-stt", error), + ), + }); + } + + let mut live = state.live.write().await; + live.routing = Arc::new(routing); + live.config = Arc::new(target.config); + #[cfg(feature = "web-search")] + { + live.web_search = target.web_search; + } + #[cfg(feature = "local")] + { + live.local = replacement.local; + } + live.profile_name = Some(name.to_string()); + live.model_allowlist = target.allowlist; + live.loading.clear(); + TerminalPhase::Committed(CommittedPhase { report }) + } +} + +/// Runs the private transaction and returns only its externally visible +/// profile outcome. +pub(super) async fn run( + state: &AppState, + name: ProfileName, + tree: ProgressTree, + candidate: Option, + persistence: impl FnOnce() -> StatePersistence, + token: &CancellationToken, +) -> Result { + let prepared = prepare(state, name.clone(), tree, candidate, persistence, token).await?; + let cutover = match prepared.cut_over().await { + Ok(phase) => phase, + Err(terminal) => return settle_terminal(terminal, &name), + }; + let staged = match cutover.stage().await { + Ok(phase) => phase, + Err(terminal) => return settle_terminal(terminal, &name), + }; + settle_terminal(staged.commit().await, &name) +} + +fn settle_terminal(terminal: TerminalPhase, name: &ProfileName) -> Result { + let report = terminal.finish()?; + #[cfg(feature = "local")] + if !report.failed.is_empty() { + return Err(GatewayError::PartialStart { + profile: name.to_string(), + loaded: report.loaded, + failed: report.failed, + }); + } + #[cfg(not(feature = "local"))] + let StartReport {} = report; + + tracing::info!(profile = %name, "switched profile"); + Ok(name.to_string()) +} + +/// Resolves and persists the target into a value that alone can cut over. +pub(super) async fn prepare( + state: &AppState, + name: ProfileName, + tree: ProgressTree, + candidate: Option, + persistence: impl FnOnce() -> StatePersistence, + token: &CancellationToken, +) -> Result { + let token = token.clone(); + if token.is_cancelled() { + return Err(switch_cancelled(&name)); + } + let target = prepare_target(state, &name, &tree, candidate).await?; + if token.is_cancelled() { + return Err(switch_cancelled(&name)); + } + let stop = stop_set(state).await; + let download_after_cutover = stop.is_empty(); + let persistence = persistence(); + if !download_after_cutover { + #[cfg(test)] + state + .park_at(crate::switch_park::SwitchPhase::Download) + .await; + download_artifacts(&target, &tree, &token).await?; + if token.is_cancelled() { + return Err(switch_cancelled(&name)); + } + } + let persistence = PreparedPersistence::prepare(state, &name, persistence).await?; + if token.is_cancelled() { + return Err(switch_cancelled(&name)); + } + Ok(PreparedPhase { + state: state.clone(), + name, + tree, + target, + stop, + persistence, + token, + download_after_cutover, + }) +} + +async fn prepare_target( + state: &AppState, + name: &ProfileName, + tree: &ProgressTree, + candidate: Option, +) -> Result { + let loading = tree.register("loading-profile", 1.0); + let catalog = match candidate { + Some(config) => config, + None => state.live.read().await.config.as_ref().clone(), + }; + let (config, remote_routing) = select_target(&catalog, name, &loading)?; + #[cfg(not(feature = "local"))] + if !config.local_models().is_empty() { + loading.fail(); + return Err(GatewayError::switch_failed( + "start-local", + std::io::Error::other(LOCAL_MODELS_UNSUPPORTED), + )); + } + #[cfg(feature = "stt")] + let speech = { + let service = state.speech.clone(); + let config = config.clone(); + let progress = loading.clone(); + tokio::task::spawn_blocking(move || service.prepare(&config, Some(&progress))) + .await + .map_err(|error| GatewayError::switch_failed("prepare-stt-task", error))? + .map_err(|error| GatewayError::switch_failed("prepare-stt", error))? + }; + loading.complete(); + + #[cfg(feature = "web-search")] + let web_search = config + .web_search_config() + .map(WebSearchState::new) + .map(Arc::new); + let allowlist = config + .active_profile() + .map(|profile| profile.models().to_vec()); + let loading = config + .local_models() + .iter() + .map(|model| model.name().to_owned()) + .collect(); + Ok(SwitchTarget { + config, + remote_routing, + #[cfg(feature = "web-search")] + web_search, + allowlist, + loading, + #[cfg(feature = "stt")] + speech, + }) +} + +fn select_target( + catalog: &Config, + name: &ProfileName, + loading: &ProgressHandle, +) -> Result<(Config, Routing), GatewayError> { + if !catalog + .profiles() + .iter() + .any(|profile| profile.name() == name.as_str()) + { + loading.fail(); + return Err(GatewayError::ProfileNotFound(name.to_string())); + } + let config = match catalog.select_profile(name) { + Ok(config) => config, + Err(error) => { + loading.fail(); + return Err(GatewayError::switch_failed("select-profile", error)); + } + }; + #[cfg(not(feature = "stt"))] + if !config.stt_models().is_empty() { + loading.fail(); + return Err(GatewayError::switch_failed( + "start-stt", + std::io::Error::other(STT_RUNTIME_UNAVAILABLE), + )); + } + let remote_routing = match Routing::from_config(&config) { + Ok(routing) => routing, + Err(error) => { + loading.fail(); + return Err(GatewayError::switch_failed("build-routing", error)); + } + }; + Ok((config, remote_routing)) +} + +fn switch_cancelled(name: &ProfileName) -> GatewayError { + GatewayError::CommandCancelled(format!("load-profile: {name}")) +} + +#[cfg(all(test, feature = "stt"))] +/// Resolves only a target for tests of the unchanged terminal commit. +pub(super) async fn prepare_target_for_test( + state: &AppState, + name: &ProfileName, + tree: &ProgressTree, + candidate: Option, +) -> Result { + let SwitchTarget { + config, + remote_routing, + #[cfg(feature = "web-search")] + web_search, + allowlist, + loading: _, + speech: _, + } = prepare_target(state, name, tree, candidate).await?; + Ok(StagedTarget { + config, + remote_routing, + #[cfg(feature = "web-search")] + web_search, + allowlist, + }) +} + +#[cfg(any(feature = "local", feature = "stt"))] +async fn stop_set(state: &AppState) -> StopSet { + #[cfg(feature = "local")] + let live = state.live.read().await; + StopSet { + #[cfg(feature = "local")] + local: live.local.child_count() > 0, + #[cfg(feature = "stt")] + stt: state.speech.status().ready(), + } +} + +#[cfg(not(any(feature = "local", feature = "stt")))] +async fn stop_set(_state: &AppState) -> StopSet { + StopSet {} +} + +#[cfg(feature = "local")] +async fn download_artifacts( + target: &SwitchTarget, + tree: &ProgressTree, + token: &CancellationToken, +) -> Result<(), GatewayError> { + if target.config.local_models().is_empty() { + return Ok(()); + } + let downloading = tree.register("downloading-models", 5.0); + let config = target.config.clone(); + let progress = downloading.clone(); + let worker_token = token.clone(); + let result = tokio::task::spawn_blocking(move || { + crate::local::LocalRuntime::provision_artifacts_with_cancellation( + &config, + Some(&progress), + &worker_token, + ) + }) + .await; + match result { + Ok(Ok(failures)) if failures.is_empty() => { + downloading.complete(); + Ok(()) + } + Ok(Ok(failures)) => { + for failure in &failures { + tracing::warn!( + model = failure.model(), + error = %failure.error(), + "local model artifact did not provision; the start reports it" + ); + } + downloading.fail(); + Ok(()) + } + Ok(Err(error)) => { + downloading.fail(); + Err(GatewayError::switch_failed("download-models", error)) + } + Err(error) => { + downloading.fail(); + Err(GatewayError::switch_failed("download-models-task", error)) + } + } +} + +#[cfg(not(feature = "local"))] +async fn download_artifacts( + _target: &SwitchTarget, + _tree: &ProgressTree, + _token: &CancellationToken, +) -> Result<(), GatewayError> { + Ok(()) +} + +async fn cut_over( + state: &AppState, + target: &SwitchTarget, + tree: &ProgressTree, + stop: StopSet, + token: &CancellationToken, +) -> Result<(), GatewayError> { + let _switch = state.switch.lock().await; + #[cfg(test)] + { + state + .park_at(crate::switch_park::SwitchPhase::CutOver) + .await; + } + tokio::select! { + () = drain_inference(state) => {} + () = token.cancelled() => { + return Err(GatewayError::CommandCancelled("profile switch".to_owned())); + } + } + let stopping = if stop.is_empty() { + None + } else { + Some(tree.register("stopping-models", 2.0)) + }; + let old = { + let mut live = state.live.write().await; + live.routing = Arc::new(target.remote_routing.clone()); + live.loading.clone_from(&target.loading); + if stopping.is_none() { + None + } else { + Some(OldRuntimes { + #[cfg(feature = "local")] + local: std::mem::replace(&mut live.local, LocalRuntime::empty()), + }) + } + }; + let (Some(stopping), Some(old)) = (stopping, old) else { + return Ok(()); + }; + match tokio::task::spawn_blocking(move || old.shutdown()).await { + Ok(Ok(())) => { + stopping.complete(); + Ok(()) + } + Ok(Err(error)) => { + stopping.fail(); + Err(GatewayError::switch_failed("shutdown-local", error)) + } + Err(error) => { + stopping.fail(); + Err(GatewayError::switch_failed("shutdown-local-task", error)) + } + } +} + +struct OldRuntimes { + #[cfg(feature = "local")] + local: LocalRuntime, +} + +impl OldRuntimes { + fn shutdown(self) -> Result<(), shared_protocol::ShutdownError> { + #[cfg(feature = "local")] + let result = self.local.shutdown(); + #[cfg(not(feature = "local"))] + let result = Ok(()); + result + } +} + +async fn drain_inference(state: &AppState) { + if !state + .in_flight + .drain_or_cancel(std::time::Duration::from_secs(30)) + .await + { + tracing::warn!( + "profile-switch cancellation grace expired; stopping local children with request guards still registered" + ); + } +} + +async fn capture_runtime_snapshot(state: &AppState) -> PriorRuntimeSnapshot { + let live = state.live.read().await; + PriorRuntimeSnapshot { + routing_was_empty: live.routing.models().is_empty(), + routing: Arc::clone(&live.routing), + config: Arc::clone(&live.config), + #[cfg(feature = "web-search")] + web_search: live.web_search.clone(), + profile_name: live.profile_name.clone(), + model_allowlist: live.model_allowlist.clone(), + loading: live.loading.clone(), + #[cfg(feature = "local")] + restart_local: !live.local.models().is_empty(), + } +} + +async fn restore_runtime_snapshot( + state: &AppState, + prior: PriorRuntimeSnapshot, +) -> Result<(), GatewayError> { + #[cfg(feature = "local")] + let local = if prior.restart_local { + let config = Arc::clone(&prior.config); + tokio::time::timeout( + STAGE_TIMEOUT, + tokio::task::spawn_blocking(move || LocalRuntime::start(&config, None)), + ) + .await + .map_err(|_| { + GatewayError::switch_failed( + "rollback-local-timeout", + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "old local runtime reconstruction exceeded its startup deadline", + ), + ) + })? + .map_err(|join| GatewayError::switch_failed("rollback-local-task", join))? + .map_err(|error| GatewayError::switch_failed("rollback-local", error))? + } else { + LocalRuntime::empty() + }; + let mut live = state.live.write().await; + if !prior.routing_was_empty { + live.routing = prior.routing; + } + live.config = prior.config; + #[cfg(feature = "web-search")] + { + live.web_search = prior.web_search; + } + live.profile_name = prior.profile_name; + live.model_allowlist = prior.model_allowlist; + live.loading = prior.loading; + #[cfg(feature = "local")] + { + live.local = local; + } + Ok(()) +} + +pub(super) fn request_fatal_shutdown( + state: &AppState, + token: &CancellationToken, + phase: &'static str, + error: GatewayError, +) -> GatewayError { + token.cancel(); + state.shutdown.fire(); + #[cfg(feature = "stt")] + state.speech.shutdown(); + GatewayError::switch_failed(phase, error) +} + +#[cfg_attr( + not(feature = "stt"), + expect( + unused_variables, + reason = "featureless runtime replacement has no speech owner to restore" + ) +)] +fn rollback_runtime(state: &AppState, replacement: RuntimeReplacement) -> Result<(), GatewayError> { + #[cfg(feature = "stt")] + state + .speech + .abort_replacement(replacement.speech) + .map_err(|error| GatewayError::switch_failed("rollback-stt", error))?; + #[cfg(not(feature = "stt"))] + let _replacement = replacement; + Ok(()) +} + +struct RollbackOwner { + state: AppState, + prior: PriorRuntimeSnapshot, + token: CancellationToken, + failure: GatewayError, + runtime_rollback: Result<(), GatewayError>, +} + +impl RollbackOwner { + async fn finish(self) -> TerminalPhase { + match self.runtime_rollback { + Ok(()) => match restore_runtime_snapshot(&self.state, self.prior).await { + Ok(()) => TerminalPhase::RolledBack(RolledBackPhase { + error: self.failure, + }), + Err(rollback) => TerminalPhase::Indeterminate(IndeterminatePhase { + error: request_fatal_shutdown( + &self.state, + &self.token, + "rollback-profile", + rollback, + ), + }), + }, + Err(rollback) => { + let failure = GatewayError::switch_failed( + "determinate-profile-failure", + std::io::Error::other(format!( + "{}; {}", + crate::config_write::error_chain(&self.failure), + crate::config_write::error_chain(&rollback) + )), + ); + TerminalPhase::Indeterminate(IndeterminatePhase { + error: request_fatal_shutdown( + &self.state, + &self.token, + "rollback-staged-profile", + failure, + ), + }) + } + } + } +} + +#[cfg(all(test, feature = "stt"))] +pub(super) async fn commit_for_test( + state: &AppState, + name: ProfileName, + target: StagedTarget, + replacement: RuntimeReplacement, + persistence: PreparedPersistence, + token: CancellationToken, +) -> Result { + let prior = capture_runtime_snapshot(state).await; + StagedPhase { + state: state.clone(), + name, + target, + replacement, + persistence, + prior, + token, + } + .commit() + .await + .finish() +} + +#[cfg(feature = "local")] +fn start_report(replacement: &RuntimeReplacement) -> StartReport { + StartReport { + loaded: replacement + .local + .models() + .iter() + .map(|model| model.name.clone()) + .collect(), + failed: replacement + .start_failures + .iter() + .map(|failure| format!("{}: {}", failure.model(), failure.error())) + .collect(), + } +} + +#[cfg(not(feature = "local"))] +fn start_report(_replacement: &RuntimeReplacement) -> StartReport { + StartReport {} +} + +#[cfg(feature = "stt")] +pub(super) fn classify_speech_stage_failure( + error: gateway_stt::SpeechError, +) -> RuntimeStageFailure { + let indeterminate = error.is_non_preemptible_startup_timeout(); + let error = GatewayError::switch_failed("start-stt", error); + if indeterminate { + RuntimeStageFailure::Indeterminate(error) + } else { + RuntimeStageFailure::Determinate(error) + } +} + +#[cfg(not(any(feature = "local", feature = "stt")))] +async fn spawn_runtimes( + _config: &Config, + _tree: &ProgressTree, + _token: &CancellationToken, + _deadline: std::time::Instant, +) -> Result { + Ok(RuntimeReplacement {}) +} + +#[cfg(any(feature = "local", feature = "stt"))] +#[expect( + clippy::too_many_lines, + reason = "the moved staging sequence preserves one shared deadline and exact local-before-speech cancellation order" +)] +async fn spawn_runtimes( + config: &Config, + #[cfg(feature = "stt")] speech: SpeechService, + #[cfg(feature = "stt")] prepared_speech: gateway_stt::PreparedSpeech, + tree: &ProgressTree, + token: &CancellationToken, + deadline: std::time::Instant, +) -> Result { + let starting = tree.register("starting-models", 5.0); + #[cfg(feature = "local")] + let start_config = config.clone(); + #[cfg(feature = "local")] + let start_progress = starting.clone(); + #[cfg(feature = "local")] + let outcome = { + let start_token = token.clone(); + let interrupted = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let bridge = tokio::spawn({ + let interrupted = Arc::clone(&interrupted); + let token = token.clone(); + async move { + token.cancelled().await; + interrupted.store(true, std::sync::atomic::Ordering::Release); + } + }); + let result = tokio::time::timeout( + deadline.saturating_duration_since(std::time::Instant::now()), + tokio::task::spawn_blocking(move || { + LocalRuntime::start_partial_with_cancellation( + &start_config, + Some(&start_progress), + &start_token, + &interrupted, + ) + }), + ) + .await; + bridge.abort(); + match result { + Ok(Ok(Ok(outcome))) => outcome, + Ok(Ok(Err(error))) => { + starting.fail(); + return Err(RuntimeStageFailure::Determinate( + GatewayError::switch_failed("start-local", error), + )); + } + Ok(Err(error)) => { + starting.fail(); + return Err(RuntimeStageFailure::Determinate( + GatewayError::switch_failed("start-local-task", error), + )); + } + Err(_) => { + starting.fail(); + return Err(RuntimeStageFailure::Indeterminate( + GatewayError::switch_failed( + "start-local-timeout", + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "local runtime startup exceeded the shared profile deadline", + ), + ), + )); + } + } + }; + #[cfg(feature = "local")] + let (runtime, failures) = outcome.into_parts(); + #[cfg(feature = "stt")] + if token.is_cancelled() { + return Err(RuntimeStageFailure::Determinate( + GatewayError::CommandCancelled("profile switch".to_owned()), + )); + } + #[cfg(feature = "stt")] + let speech = match tokio::task::spawn_blocking(move || { + speech.begin_replacement_before(prepared_speech, deadline) + }) + .await + { + Ok(Ok(runtime)) => runtime, + Ok(Err(error)) => { + starting.fail(); + return Err(classify_speech_stage_failure(error)); + } + Err(error) => { + starting.fail(); + return Err(RuntimeStageFailure::Determinate( + GatewayError::switch_failed("start-stt-task", error), + )); + } + }; + #[cfg(feature = "local")] + if failures.is_empty() { + starting.complete(); + } else { + starting.fail(); + } + #[cfg(not(feature = "local"))] + starting.complete(); + Ok(RuntimeReplacement { + #[cfg(feature = "local")] + local: runtime, + #[cfg(feature = "local")] + start_failures: failures, + #[cfg(feature = "stt")] + speech, + }) +} + +#[cfg(test)] +mod tests { + use gateway_config::{Config, ProfileName}; + use tokio_util::sync::CancellationToken; + + use crate::error::GatewayError; + use crate::test_support::app_state; + + fn state() -> crate::AppState { + let catalog = Config::from_toml_str( + "config-version = 2\n\ + [server]\nbind = \"127.0.0.1:0\"\napi_key = \"test-token\"\n\ + [[endpoint]]\nid = \"fake\"\nprotocol = \"openai\"\nbase_url = \"http://127.0.0.1:9\"\napi_key = \"\"\n\ + [[model]]\nname = \"alpha-model\"\ndescription = \"alpha\"\ncontext = 1024\nupstream = \"alpha\"\nendpoints = [\"fake\"]\n\ + [[model]]\nname = \"beta-model\"\ndescription = \"beta\"\ncontext = 1024\nupstream = \"beta\"\nendpoints = [\"fake\"]\n\ + [[profile]]\nname = \"alpha\"\nmodels = [\"alpha-model\"]\n\ + [[profile]]\nname = \"beta\"\nmodels = [\"beta-model\"]\n", + ) + .expect("catalog parses"); + let config = catalog + .select_profile(&ProfileName::parse("alpha").expect("profile name")) + .expect("alpha profile selects"); + app_state(config, None) + } + + #[test] + fn prepared_file_retries_deterministic_collisions_without_claiming_residue() { + let temp = tempfile::tempdir().expect("tempdir"); + let target = temp.path().join("gateway.state.toml"); + std::fs::write(&target, "old").expect("write target"); + let pid = 41; + let nonce = 0x1234; + let collision = super::persistence_temporary(&target, pid, nonce, 7); + let owned = super::persistence_temporary(&target, pid, nonce, 8); + std::fs::write(&collision, "crash residue").expect("write collision"); + let mut sequences = [7, 8].into_iter(); + + let prepared = + super::PreparedFile::prepare_with_names(target, "new".to_owned(), pid, nonce, || { + sequences.next().expect("bounded sequence") + }) + .expect("collision retries"); + + assert_eq!( + std::fs::read_to_string(&collision).expect("read residue"), + "crash residue" + ); + assert_eq!( + std::fs::read_to_string(&owned).expect("read preparation"), + "new" + ); + drop(prepared); + assert!(collision.exists(), "unowned residue remains"); + assert!(!owned.exists(), "owned preparation is cleaned"); + } + + #[test] + fn process_name_source_is_stable_full_width_and_unique_across_pid_reuse() { + let temp = tempfile::tempdir().expect("tempdir"); + let target = temp.path().join("gateway.state.toml"); + let pid = 73; + let first_nonce = 0x0123_4567_89ab_cdef_fedc_ba98_7654_3210; + let second_nonce = 0xfedc_ba98_7654_3210_0123_4567_89ab_cdef; + let first_nonce_calls = std::cell::Cell::new(0); + let first_source = super::ProcessPreparationNames::new(pid, || { + first_nonce_calls.set(first_nonce_calls.get() + 1); + first_nonce + }); + let second_source = super::ProcessPreparationNames::new(pid, || second_nonce); + + let first = super::PreparedFile::prepare_with_name_source( + target.clone(), + "first preparation".to_owned(), + &first_source, + ) + .expect("first process prepares"); + let next = super::PreparedFile::prepare_with_name_source( + target.clone(), + "next preparation".to_owned(), + &first_source, + ) + .expect("same process prepares again"); + let reused = super::PreparedFile::prepare_with_name_source( + target, + "reused PID preparation".to_owned(), + &second_source, + ) + .expect("reused PID prepares"); + + assert_eq!(first_nonce_calls.get(), 1, "one nonce per process source"); + assert_eq!( + first + .temporary + .as_deref() + .and_then(std::path::Path::file_name), + Some(std::ffi::OsStr::new( + "gateway.state.toml.prepared-73-0123456789abcdeffedcba9876543210-0" + )) + ); + assert_eq!( + next.temporary + .as_deref() + .and_then(std::path::Path::file_name), + Some(std::ffi::OsStr::new( + "gateway.state.toml.prepared-73-0123456789abcdeffedcba9876543210-1" + )) + ); + assert_eq!( + reused + .temporary + .as_deref() + .and_then(std::path::Path::file_name), + Some(std::ffi::OsStr::new( + "gateway.state.toml.prepared-73-fedcba98765432100123456789abcdef-0" + )) + ); + } + + #[test] + fn process_nonce_separates_pid_reuse_from_crash_residue() { + let temp = tempfile::tempdir().expect("tempdir"); + let target = temp.path().join("gateway.state.toml"); + let pid = 73; + let crashed = super::persistence_temporary(&target, pid, 0xaaaa, 0); + let current = super::persistence_temporary(&target, pid, 0xbbbb, 0); + std::fs::write(&crashed, "prior process").expect("write crash residue"); + + let prepared = super::PreparedFile::prepare_with_names( + target, + "current process".to_owned(), + pid, + 0xbbbb, + || 0, + ) + .expect("reused PID prepares"); + + assert_eq!( + std::fs::read_to_string(&crashed).expect("read crash residue"), + "prior process" + ); + assert_eq!( + std::fs::read_to_string(¤t).expect("read current preparation"), + "current process" + ); + drop(prepared); + assert!(crashed.exists(), "prior process residue remains"); + } + + #[test] + fn prepared_file_bounds_collision_retries() { + let temp = tempfile::tempdir().expect("tempdir"); + let target = temp.path().join("gateway.state.toml"); + let pid = 97; + let nonce = 0xcafe; + for sequence in 0..super::PREPARED_CREATE_ATTEMPTS { + std::fs::write( + super::persistence_temporary(&target, pid, nonce, sequence), + format!("residue {sequence}"), + ) + .expect("write residue"); + } + let mut sequence = 0_u64; + + let error = super::PreparedFile::prepare_with_names( + target.clone(), + "new".to_owned(), + pid, + nonce, + || { + let current = sequence; + sequence += 1; + current + }, + ) + .expect_err("retry budget exhausts"); + + let GatewayError::ConfigWriteIo(error) = error else { + panic!("collision exhaustion returns an I/O error"); + }; + let error = error.downcast_ref::().expect("I/O source"); + let last_candidate = + super::persistence_temporary(&target, pid, nonce, super::PREPARED_CREATE_ATTEMPTS - 1); + assert_eq!( + error.to_string(), + format!( + "failed to prepare {} after {} create_new attempts; last candidate {}", + target.display(), + super::PREPARED_CREATE_ATTEMPTS, + last_candidate.display() + ) + ); + assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists); + let context = error + .get_ref() + .and_then(|source| source.downcast_ref::()) + .expect("collision exhaustion context"); + assert_eq!(context.attempts, super::PREPARED_CREATE_ATTEMPTS); + assert_eq!(context.last_candidate, last_candidate); + let collision = std::error::Error::source(error) + .and_then(|source| source.downcast_ref::()) + .expect("final collision source"); + assert_eq!(collision.kind(), std::io::ErrorKind::AlreadyExists); + assert_eq!(sequence, super::PREPARED_CREATE_ATTEMPTS); + for residue in 0..super::PREPARED_CREATE_ATTEMPTS { + assert_eq!( + std::fs::read_to_string( + super::persistence_temporary(&target, pid, nonce, residue,) + ) + .expect("read residue"), + format!("residue {residue}") + ); + } + } + + #[test] + fn successful_commit_releases_temporary_path_ownership() { + let temp = tempfile::tempdir().expect("tempdir"); + let target = temp.path().join("gateway.state.toml"); + std::fs::write(&target, "old").expect("write target"); + let temporary = super::persistence_temporary(&target, 101, 0xfeed, 3); + let mut prepared = super::PreparedFile::prepare_with_names( + target.clone(), + "new".to_owned(), + 101, + 0xfeed, + || 3, + ) + .expect("prepare"); + + prepared.commit().expect("commit"); + assert_eq!( + std::fs::read_to_string(&target).expect("read target"), + "new" + ); + assert!(!temporary.exists(), "rename consumes preparation"); + std::fs::write(&temporary, "later owner").expect("replace temporary path"); + drop(prepared); + assert_eq!( + std::fs::read_to_string(&temporary).expect("read later owner"), + "later owner" + ); + } + + #[test] + fn persistence_failure_classification_distinguishes_untouched_from_uncertain_state() { + let temp = tempfile::tempdir().expect("tempdir"); + let target = temp.path().join("gateway.state.toml"); + std::fs::write(&target, "active_profile = \"alpha\"\n").expect("write old state"); + + let determinate = super::PreparedPersistence::for_test( + target.clone(), + "active_profile = \"beta\"\n".to_owned(), + ) + .expect("prepare determinate fixture"); + determinate.discard_temporaries(); + let error = determinate + .commit_blocking() + .expect_err("missing temporary prevents commit"); + assert!(matches!( + error, + super::PersistenceCommitError::Determinate(_) + )); + + let indeterminate = super::PreparedPersistence::for_test( + target.clone(), + "active_profile = \"beta\"\n".to_owned(), + ) + .expect("prepare indeterminate fixture"); + std::fs::write(&target, "unrecognized contents").expect("replace authoritative state"); + indeterminate.discard_temporaries(); + let error = indeterminate + .commit_blocking() + .expect_err("missing temporary prevents commit"); + assert!(matches!( + error, + super::PersistenceCommitError::Indeterminate(_) + )); + } + + #[tokio::test] + async fn preparation_produces_a_prepared_phase_without_publishing_target() { + let state = state(); + let tree = state.hub.operation(); + let token = CancellationToken::new(); + let prepared = super::prepare( + &state, + ProfileName::parse("beta").expect("profile name"), + tree, + None, + || super::StatePersistence::None, + &token, + ) + .await + .expect("preparation succeeds"); + + assert_eq!( + prepared + .target + .config + .active_profile() + .expect("target profile") + .name(), + "beta" + ); + let live = state.live.read().await; + assert!(live.routing.model("alpha-model").is_ok()); + assert!(live.routing.model("beta-model").is_err()); + } + + #[tokio::test] + async fn prepared_phase_transitions_once_to_cutover_with_prior_snapshot() { + let state = state(); + let tree = state.hub.operation(); + let token = CancellationToken::new(); + let prepared = super::prepare( + &state, + ProfileName::parse("beta").expect("profile name"), + tree, + None, + || super::StatePersistence::None, + &token, + ) + .await + .expect("preparation succeeds"); + + let cutover = prepared.cut_over().await.expect("cutover succeeds"); + + assert!(cutover.prior.routing.model("alpha-model").is_ok()); + assert!(cutover.prior.routing.model("beta-model").is_err()); + let live = state.live.read().await; + assert!(live.routing.model("alpha-model").is_err()); + assert!(live.routing.model("beta-model").is_ok()); + } +} diff --git a/crates/gateway/src/relaunch.rs b/crates/gateway/src/relaunch.rs index 56fc3c8c..aa04726c 100644 --- a/crates/gateway/src/relaunch.rs +++ b/crates/gateway/src/relaunch.rs @@ -54,6 +54,11 @@ pub fn running_gateway_settings_url(options: &ServeOptions) -> Option { let resolution = match shared_sidecar::resolve(&run_dir) { Ok(resolution) => resolution, Err(error) => { + // The binary's handoff check runs before `init_logging` (a + // relaunch must not rotate the running gateway's log), so with + // no subscriber installed this warn is dropped there; the boot + // that follows logs its own connection-file failure once + // logging is live. tracing::warn!( "could not resolve the connection file in {}: {error}; booting normally", run_dir.display() diff --git a/crates/gateway/src/runner.rs b/crates/gateway/src/runner.rs index f19267df..caee09aa 100644 --- a/crates/gateway/src/runner.rs +++ b/crates/gateway/src/runner.rs @@ -205,7 +205,7 @@ impl Gateway { #[cfg(feature = "local")] LocalRuntime::empty(), #[cfg(feature = "stt")] - gateway_stt::SttRuntime::empty(gateway_stt::SttState::default()), + gateway_stt::SpeechService::new(), #[cfg(feature = "web-search")] config.web_search_config(), profiles.config_path, @@ -297,11 +297,17 @@ impl Gateway { ))); } #[cfg(feature = "stt")] - let stt = { + let speech = { let tree = hub.operation(); let progress = tree.register("startup-stt", 1.0); - let state = gateway_stt::SttState::default(); - let started = gateway_stt::SttRuntime::start(config, state, Some(&progress)) + let service = gateway_stt::SpeechService::new(); + let started = service + .prepare(config, Some(&progress)) + .and_then(|prepared| service.begin_replacement(prepared)) + .and_then(|replacement| { + service.commit_replacement(replacement)?; + Ok(service) + }) .map_err(StartupError::provisioning); match &started { Ok(_) => progress.complete(), @@ -335,7 +341,7 @@ impl Gateway { #[cfg(feature = "local")] local, #[cfg(feature = "stt")] - stt, + speech, #[cfg(feature = "web-search")] config.web_search_config(), profiles.config_path, @@ -363,6 +369,18 @@ impl Gateway { build_router(self.state.clone(), None) } + /// Replaces the speech facade used by routes and profile transitions. + /// + /// This composition seam lets embedders provide an already prepared + /// speech generation while preserving the Gateway's authentication, + /// host-authority, and route-layer policies. + #[cfg(feature = "stt")] + #[must_use] + pub fn with_speech_service(mut self, service: gateway_stt::SpeechService) -> Self { + self.state.speech = service; + self + } + /// Bounded stdout/stderr tails captured from each running local /// `llama-server` child, keyed by configured model name. /// @@ -396,9 +414,9 @@ impl Gateway { /// Shutdown fires the route signal (so every open-ended stream ends), /// closes the queue (so the active command cancels and nothing pending /// starts), then drains in-flight requests for at most - /// [`GRACEFUL_DRAIN_TIMEOUT`]; a connection that outlives the drain is + /// `GRACEFUL_DRAIN_TIMEOUT`; a connection that outlives the drain is /// dropped with the runtime rather than pinning the exit. The command - /// worker is then joined for at most [`WORKER_JOIN_TIMEOUT`]: a command + /// worker is then joined for at most `WORKER_JOIN_TIMEOUT`: a command /// body that ignored its cancellation token is abandoned to the runtime /// teardown instead of pinning the exit. /// @@ -1185,15 +1203,14 @@ fn load_startup_with_environment( /// section, or `None` when the section is absent. The gateway no longer /// hosts the workshop - the desktop shell embeds the workshop server /// itself - so the section's `bind` and `open_browser` settings do -/// nothing. The section still parses (an existing config must not fail), -/// and `[workshop.stt]` capture tuning still applies to the STT engine; -/// the warning is what keeps the inert fields from being silently +/// nothing. The section still parses so existing hosting settings do not +/// break startup; the warning keeps those inert fields from being silently /// ignored. fn workshop_section_deprecation(config: &Config) -> Option<&'static str> { config.workshop().is_some().then_some( "the [workshop] section is deprecated: the gateway hosts no workshop listener \ (the desktop shell embeds the workshop server itself); its bind and open_browser \ - settings are ignored, while [workshop.stt] capture tuning still applies", + settings are ignored", ) } @@ -1358,8 +1375,8 @@ models = ["beta-model"] "the warning names the section: {warning}" ); assert!( - warning.contains("[workshop.stt]"), - "the warning names what still applies: {warning}" + !warning.contains("[workshop.stt]"), + "the warning must not advertise the legacy STT section: {warning}" ); } diff --git a/crates/gateway/src/shutdown.rs b/crates/gateway/src/shutdown.rs index 4f51afa9..1236feb7 100644 --- a/crates/gateway/src/shutdown.rs +++ b/crates/gateway/src/shutdown.rs @@ -37,7 +37,6 @@ impl ShutdownSignal { /// Whether the signal has been fired; the tray's status tick reads it /// to tell a requested shutdown apart from a serve-loop failure. - #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] pub(crate) fn is_fired(&self) -> bool { self.token.is_cancelled() } @@ -117,7 +116,6 @@ mod tests { /// The tray's status tick reads `is_fired` synchronously to tell a /// requested shutdown apart from a serve-loop failure; the method is /// gated on the tray backends like its only callers. - #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] #[test] fn fire_sets_the_synchronous_peek() { let signal = super::ShutdownSignal::default(); diff --git a/crates/gateway/src/system.rs b/crates/gateway/src/system.rs index d733edc7..8a037a36 100644 --- a/crates/gateway/src/system.rs +++ b/crates/gateway/src/system.rs @@ -23,6 +23,28 @@ use crate::auth::Caller; use crate::error::GatewayError; use crate::{AppState, check_auth}; +/// Generic speech lifecycle facts included in Gateway operational status. +#[cfg(feature = "stt")] +#[derive(Debug, Clone, Copy, Serialize)] +pub(crate) struct SpeechSnapshot { + configured: bool, + ready: bool, + gpu: bool, + generation: Option, +} + +#[cfg(feature = "stt")] +impl From for SpeechSnapshot { + fn from(status: gateway_stt::SpeechStatus) -> Self { + Self { + configured: status.configured(), + ready: status.ready(), + gpu: status.gpu(), + generation: status.generation(), + } + } +} + /// One `GET /admin/system` snapshot. #[derive(Debug, Clone, Serialize)] pub(crate) struct SystemSnapshot { @@ -287,6 +309,22 @@ mod tests { use crate::test_support::serve; + #[cfg(feature = "stt")] + #[test] + fn speech_snapshot_serializes_only_generic_facade_facts() { + let snapshot = super::SpeechSnapshot::from(gateway_stt::SpeechService::new().status()); + + assert_eq!( + serde_json::json!(snapshot), + serde_json::json!({ + "configured": false, + "ready": false, + "gpu": false, + "generation": null, + }) + ); + } + /// A minimal profile rooting the artifact cache at `cache_dir`. fn system_config(cache_dir: &std::path::Path) -> Config { Config::from_toml_str(&format!( diff --git a/crates/gateway/src/test_support.rs b/crates/gateway/src/test_support.rs index 7e195f7d..6f3ac61a 100644 --- a/crates/gateway/src/test_support.rs +++ b/crates/gateway/src/test_support.rs @@ -62,6 +62,19 @@ pub(crate) fn app_state(config: Config, paths: Option) -> AppState { state_over(config, routing, paths) } +/// Builds state with deterministic speech workers for Gateway route tests. +#[cfg(feature = "stt")] +pub(crate) fn app_state_with_scripted_stt( + config: Config, + factory: gateway_stt::test_fixtures::ScriptedModelFactory, +) -> Result { + let service = gateway_stt::test_fixtures::scripted_service(factory, 15, 500) + .map_err(|error| error.to_string())?; + let mut state = app_state(config, None); + state.speech = service; + Ok(state) +} + /// Builds the state the instant-ready boot path serves: an empty routing /// table over `config`, no active profile, nothing local running - the /// shell the boot `LoadProfile` command fills. @@ -90,7 +103,7 @@ fn state_over(config: Config, routing: Routing, paths: Option) -> Ap #[cfg(feature = "local")] crate::local::LocalRuntime::empty(), #[cfg(feature = "stt")] - gateway_stt::SttRuntime::empty(gateway_stt::SttState::default()), + gateway_stt::SpeechService::new(), #[cfg(feature = "web-search")] config.web_search_config(), config_path, @@ -117,3 +130,190 @@ pub(crate) async fn serve_state(state: AppState) -> SocketAddr { }); addr } + +#[cfg(all(test, feature = "stt"))] +mod tests { + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use gateway_stt::test_fixtures::{ScriptedDecoder, ScriptedModelFactory}; + use tower::ServiceExt; + + use super::*; + + fn transcription_body() -> (String, Vec) { + const BOUNDARY: &str = "scripted-stt-boundary"; + let mut wav = vec![ + b'R', b'I', b'F', b'F', 38, 0, 0, 0, b'W', b'A', b'V', b'E', b'f', b'm', b't', b' ', + 16, 0, 0, 0, 1, 0, 1, 0, 0x80, 0x3e, 0, 0, 0x00, 0x7d, 0, 0, 2, 0, 16, 0, b'd', b'a', + b't', b'a', 2, 0, 0, 0, 0, 32, + ]; + let mut body = format!( + "--{BOUNDARY}\r\nContent-Disposition: form-data; name=\"model\"\r\n\r\n\ + scripted-interim\r\n\ + --{BOUNDARY}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"sample.wav\"\r\n\ + Content-Type: audio/wav\r\n\r\n" + ) + .into_bytes(); + body.append(&mut wav); + body.extend_from_slice(format!("\r\n--{BOUNDARY}--\r\n").as_bytes()); + (BOUNDARY.to_owned(), body) + } + + #[tokio::test] + async fn scripted_workers_can_be_injected_without_a_production_constructor() { + const TRANSCRIPT: &str = "gateway scripted route sentinel"; + + let config = Config::from_toml_str( + "config-version = 2\n\ + [server]\nbind = \"127.0.0.1:0\"\napi_key = \"test-token\"\n", + ) + .expect("config parses"); + let decoder = ScriptedDecoder::new(); + decoder.push_text(TRANSCRIPT); + let state = app_state_with_scripted_stt(config, ScriptedModelFactory::new(decoder.clone())) + .expect("scripted state builds"); + let (boundary, body) = transcription_body(); + + let response = build_router(state, None) + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/audio/transcriptions") + .header("authorization", "Bearer test-token") + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .expect("request builds"), + ) + .await + .expect("router answers"); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body reads"); + let response: serde_json::Value = + serde_json::from_slice(&body).expect("response body is JSON"); + assert_eq!(response["text"], TRANSCRIPT); + let requests = decoder.requests(); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].mode(), + gateway_stt::test_fixtures::DecodeMode::Interim + ); + assert_eq!(requests[0].samples(), &[0.25]); + assert!(requests[0].guidance().is_empty()); + assert!(requests[0].finalized().is_empty()); + } + + #[tokio::test] + async fn batch_inference_preserves_the_gateway_error_message_contract() { + let config = Config::from_toml_str( + "config-version = 2\n\ + [server]\nbind = \"127.0.0.1:0\"\napi_key = \"test-token\"\n", + ) + .expect("config parses"); + let decoder = ScriptedDecoder::new(); + decoder.push_error("scripted inference sentinel"); + let state = app_state_with_scripted_stt(config, ScriptedModelFactory::new(decoder)) + .expect("scripted state builds"); + let (boundary, body) = transcription_body(); + + let response = build_router(state, None) + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/audio/transcriptions") + .header("authorization", "Bearer test-token") + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .expect("request builds"), + ) + .await + .expect("router answers"); + + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body reads"); + let response: serde_json::Value = + serde_json::from_slice(&body).expect("response body is JSON"); + assert_eq!( + response, + serde_json::json!({ + "error": { + "message": "transcription failed", + "type": "server_error", + "code": "transcription_error", + } + }) + ); + } + + async fn get_json(state: AppState, uri: &'static str) -> serde_json::Value { + let response = build_router(state, None) + .oneshot( + Request::builder() + .uri(uri) + .header("authorization", "Bearer test-token") + .body(Body::empty()) + .expect("request builds"), + ) + .await + .expect("router answers"); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body reads"); + serde_json::from_slice(&body).expect("response body is JSON") + } + + #[tokio::test] + async fn ready_scripted_pair_is_published_through_gateway_surfaces() { + let config = Config::from_toml_str( + "config-version = 2\n\ + [server]\nbind = \"127.0.0.1:0\"\napi_key = \"test-token\"\n", + ) + .expect("config parses"); + let factory = ScriptedModelFactory::new(ScriptedDecoder::new()) + .with_final(ScriptedDecoder::new()) + .with_gpu_available(true); + let state = app_state_with_scripted_stt(config, factory).expect("scripted state builds"); + let service = state.speech.clone(); + + let status = get_json(state.clone(), "/admin/status").await; + assert_eq!( + status["speech"], + serde_json::json!({ + "configured": true, + "ready": true, + "gpu": true, + "generation": 1, + }) + ); + let speech_endpoint = status["endpoints"] + .as_array() + .expect("endpoints are an array") + .iter() + .find(|entry| entry["path"] == "/v1/audio/transcriptions") + .expect("speech endpoint is present"); + assert_eq!(speech_endpoint["ready"], true); + assert_eq!(speech_endpoint["provisioning"], false); + + let catalog = get_json(state, "/v1/models").await; + assert_eq!( + catalog["data"] + .as_array() + .expect("catalog data") + .iter() + .map(|model| model["id"].as_str().expect("model id")) + .collect::>(), + ["scripted-interim", "scripted-final", "realtime-transcribe"] + ); + service.shutdown(); + } +} diff --git a/crates/gateway/src/tray/logic.rs b/crates/gateway/src/tray/logic.rs index c5818cb1..f8c8c776 100644 --- a/crates/gateway/src/tray/logic.rs +++ b/crates/gateway/src/tray/logic.rs @@ -174,16 +174,16 @@ pub(crate) fn launch_at_login(store: &dyn RunKeyStore) -> bool { } /// The login command line for the gateway executable: the quoted path -/// (install paths contain spaces) plus `serve --login` - the CLI requires -/// the `serve` subcommand, and `--login` marks a login-triggered start so -/// it never opens a browser. This is the Windows Run-key shape, whose +/// (install paths contain spaces) plus `--login` - the bare invocation +/// serves, and `--login` marks a login-triggered start so it never opens +/// a browser. This is the Windows Run-key shape, whose /// parser has no escape layer; the desktop-entry Exec shape is /// `linux::exec_command`. Gated on its callers: the Windows and macOS /// backends (macOS's store ignores the command but the call sites share /// `set_launch_at_login`), plus the tests. #[cfg(any(target_os = "windows", target_os = "macos", test))] pub(crate) fn run_key_command(exe: &Path) -> String { - format!("\"{}\" serve --login", exe.display()) + format!("\"{}\" --login", exe.display()) } /// Sets or clears the OS autostart entry, returning the state now in @@ -351,8 +351,8 @@ pub(crate) mod linux { /// The Exec line's command: the exe path double-quoted with the /// desktop-entry spec's reserved characters (`"`, `` ` ``, `$`, `\`) - /// backslash-escaped, plus `serve --login` - the CLI requires the - /// `serve` subcommand. The shared `run_key_command` + /// backslash-escaped, plus `--login` - the bare invocation serves. + /// The shared `run_key_command` /// quotes for the Windows Run key, whose parser has no escape layer; /// the desktop-entry parser does, so an install path containing a /// reserved character would misparse without the escaping. @@ -367,12 +367,12 @@ pub(crate) mod linux { quoted.push(ch); } quoted.push('"'); - format!("{quoted} serve --login") + format!("{quoted} --login") } /// The autostart entry's contents: `Terminal=false` (a daemon, not a /// terminal program), and the Exec line is the login command - the - /// quoted exe plus `serve --login`, so a login-triggered start never + /// quoted exe plus `--login`, so a login-triggered start never /// opens a browser. The app-grid launcher is packaging's file; this /// writer serves the autostart toggle. pub(crate) fn desktop_entry(exec_command: &str) -> String { @@ -652,7 +652,7 @@ mod tests { run_key_command(Path::new( "C:\\Program Files\\PromptForge\\promptforge-gateway.exe" )), - "\"C:\\Program Files\\PromptForge\\promptforge-gateway.exe\" serve --login" + "\"C:\\Program Files\\PromptForge\\promptforge-gateway.exe\" --login" ); } @@ -666,7 +666,7 @@ mod tests { assert!(enabled); assert_eq!( store.value.as_deref(), - Some("\"C:\\PromptForge\\promptforge-gateway.exe\" serve --login") + Some("\"C:\\PromptForge\\promptforge-gateway.exe\" --login") ); assert!(launch_at_login(&store), "the state reads from the store"); @@ -868,27 +868,27 @@ mod tests { fn the_exec_command_quotes_spaces_and_escapes_reserved_characters() { assert_eq!( exec_command(Path::new("/opt/Prompt Forge/promptforge-gateway")), - "\"/opt/Prompt Forge/promptforge-gateway\" serve --login" + "\"/opt/Prompt Forge/promptforge-gateway\" --login" ); assert_eq!( exec_command(Path::new("/opt/weird$`\\\"dir/promptforge-gateway")), - "\"/opt/weird\\$\\`\\\\\\\"dir/promptforge-gateway\" serve --login", + "\"/opt/weird\\$\\`\\\\\\\"dir/promptforge-gateway\" --login", "the desktop-entry parser's reserved characters are backslash-escaped" ); } #[test] fn the_desktop_entry_is_a_daemon_autostart_file() { - let entry = desktop_entry("\"/opt/Prompt Forge/promptforge-gateway\" serve --login"); + let entry = desktop_entry("\"/opt/Prompt Forge/promptforge-gateway\" --login"); assert_eq!( entry, "[Desktop Entry]\n\ Type=Application\n\ Name=PromptForge Gateway\n\ Comment=PromptForge inference gateway\n\ - Exec=\"/opt/Prompt Forge/promptforge-gateway\" serve --login\n\ + Exec=\"/opt/Prompt Forge/promptforge-gateway\" --login\n\ Terminal=false\n", - "the Exec line carries the quoted exe and serve --login; Terminal=false" + "the Exec line carries the quoted exe and --login; Terminal=false" ); } diff --git a/crates/gateway/tests/it/boot.rs b/crates/gateway/tests/it/boot.rs index f40d046e..7f80c060 100644 --- a/crates/gateway/tests/it/boot.rs +++ b/crates/gateway/tests/it/boot.rs @@ -212,27 +212,31 @@ models = ["missing-model"] handle.shutdown().expect("graceful shutdown"); } -/// A headless `serve` writes its startup line to the log file under the -/// state dir: the real binary is spawned with the profile directory -/// redirected into a temp dir (via the home variables `home_dir` reads), so -/// the run touches nothing outside it - not the connection file, not the -/// already-running handoff, not the logs. +/// A headless invocation with `--config` bookends its serving log: the +/// versioned launch record is first, and route-driven shutdown leaves the +/// clean terminal record last. The real binary is spawned with the profile +/// directory redirected into a temp dir (via the home variables `home_dir` +/// reads), so the run touches nothing outside it. #[test] -fn headless_serve_writes_the_startup_line_to_the_log_file() { +fn headless_serve_bookends_the_log_file() { let temp = tempfile::tempdir().unwrap(); let path = write_config( &temp, - "config-version = 2\n\n[server]\nbind = \"127.0.0.1:0\"\napi_key = \"test-token\"\n" + "config-version = 2\n\n[server]\nbind = \"127.0.0.1:0\"\napi_key = \"test-token\"\n\n\ + [[profile]]\nname = \"main\"\nmodels = []\n" .to_string(), ); + let run_dir = temp.path().join(".promptforge").join("run"); let log = temp .path() .join(".promptforge") .join("logs") .join("gateway.log"); let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_promptforge-gateway")) - .arg("serve") + .arg("--config") .arg(&path) + .arg("--profile") + .arg("main") .arg("--no-tray") .env("USERPROFILE", temp.path()) .env("HOME", temp.path()) @@ -242,26 +246,455 @@ fn headless_serve_writes_the_startup_line_to_the_log_file() { .spawn() .expect("the gateway binary spawns"); let deadline = std::time::Instant::now() + Duration::from_secs(30); - let contents = loop { - if log.is_file() { - let text = std::fs::read_to_string(&log).expect("read the log file"); - if text.contains("logging to") { - break text; - } + let connection = loop { + if let Some(file) = + shared_sidecar::ConnectionFile::read(&run_dir).expect("read the connection file") + { + break file; } assert!( std::time::Instant::now() < deadline, - "the startup line landed in {}", - log.display() + "the gateway bound and wrote {}", + run_dir.join("gateway.json").display() ); std::thread::sleep(Duration::from_millis(50)); }; - let _ = child.kill(); - let _ = child.wait(); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let response = runtime + .block_on(async { + reqwest::Client::new() + .post(format!("http://127.0.0.1:{}/shutdown", connection.port)) + .bearer_auth(&connection.api_key) + .send() + .await + }) + .expect("the shutdown POST answers"); + assert_eq!(response.status(), reqwest::StatusCode::ACCEPTED); + drop(runtime); + let deadline = std::time::Instant::now() + Duration::from_secs(30); + let status = loop { + if let Some(status) = child.try_wait().expect("poll the gateway process") { + break status; + } + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + panic!("the route-driven shutdown did not stop the gateway"); + } + std::thread::sleep(Duration::from_millis(50)); + }; + assert!(status.success(), "the gateway exits cleanly: {status}"); + + let contents = std::fs::read_to_string(&log).expect("read the drained log file"); + let lines = contents.lines().collect::>(); assert!( contents.contains("gateway.log"), "the startup line names the log path: {contents}" ); + assert!( + lines.first().is_some_and(|line| line.contains(&format!( + "promptforge-gateway {} starting", + env!("CARGO_PKG_VERSION") + ))), + "the versioned launch record is first: {contents}" + ); + assert!( + lines + .last() + .is_some_and(|line| line.contains("gateway exiting")), + "the clean terminal record is last: {contents}" + ); +} + +/// The bare invocation needs no subcommand: with no `--config` the gateway +/// runs boot discovery, generates the first-run config into the redirected +/// profile, and serves - proved by the connection file written after the +/// bind. The child is killed once the file lands, before the generated +/// config's boot command can provision anything. +#[test] +fn the_root_invocation_serves_with_boot_discovery() { + let temp = tempfile::tempdir().unwrap(); + let connection = temp + .path() + .join(".promptforge") + .join("run") + .join("gateway.json"); + let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_promptforge-gateway")) + .arg("--no-tray") + .env("USERPROFILE", temp.path()) + .env("HOME", temp.path()) + .env_remove("RUST_LOG") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("the gateway binary spawns"); + let deadline = std::time::Instant::now() + Duration::from_secs(30); + while !connection.is_file() { + assert!( + std::time::Instant::now() < deadline, + "the discovered boot bound and wrote {}", + connection.display() + ); + std::thread::sleep(Duration::from_millis(50)); + } + let _ = child.kill(); + let _ = child.wait(); + assert!( + temp.path() + .join(".promptforge") + .join("gateway.toml") + .is_file(), + "first-run generation wrote the profile config" + ); +} + +/// A second launch hands off to the running gateway and exits: under +/// `--print-url` it prints the running gateway's own Settings URL. Because +/// the handoff runs before logging starts, the running gateway's log is +/// never rotated and gains no second startup line. +#[test] +fn a_second_instance_hands_off_without_rotating_the_log() { + let temp = tempfile::tempdir().unwrap(); + let path = write_config( + &temp, + "config-version = 2\n\n[server]\nbind = \"127.0.0.1:0\"\napi_key = \"test-token\"\n\n\ + [[profile]]\nname = \"main\"\nmodels = []\n" + .to_string(), + ); + let logs = temp.path().join(".promptforge").join("logs"); + let connection = temp + .path() + .join(".promptforge") + .join("run") + .join("gateway.json"); + let mut first = std::process::Command::new(env!("CARGO_BIN_EXE_promptforge-gateway")) + .arg("--config") + .arg(&path) + .arg("--profile") + .arg("main") + .arg("--no-tray") + .env("USERPROFILE", temp.path()) + .env("HOME", temp.path()) + .env_remove("RUST_LOG") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("the first gateway spawns"); + let deadline = std::time::Instant::now() + Duration::from_secs(30); + while !connection.is_file() { + assert!( + std::time::Instant::now() < deadline, + "the first gateway bound and wrote {}", + connection.display() + ); + std::thread::sleep(Duration::from_millis(50)); + } + let file: Value = serde_json::from_str( + &std::fs::read_to_string(&connection).expect("read the connection file"), + ) + .expect("the connection file is JSON"); + let port = file["port"].as_u64().expect("the file carries a port"); + + // The second launch: the handoff prints the running gateway's URL and + // exits. A regression to a normal boot would serve instead, so the + // exit wait is bounded and the kill is the failure path. + let mut second = std::process::Command::new(env!("CARGO_BIN_EXE_promptforge-gateway")) + .arg("--config") + .arg(&path) + .arg("--profile") + .arg("main") + .arg("--print-url") + .env("USERPROFILE", temp.path()) + .env("HOME", temp.path()) + .env_remove("RUST_LOG") + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("the second gateway spawns"); + let deadline = std::time::Instant::now() + Duration::from_secs(30); + let status = loop { + if let Some(status) = second.try_wait().expect("poll the second instance") { + break status; + } + if std::time::Instant::now() >= deadline { + let _ = second.kill(); + panic!("the second instance booted a duplicate server instead of handing off"); + } + std::thread::sleep(Duration::from_millis(50)); + }; + assert!(status.success(), "the handoff exits successfully: {status}"); + let mut stdout = String::new(); + std::io::Read::read_to_string( + &mut second.stdout.take().expect("piped stdout"), + &mut stdout, + ) + .expect("read the second instance's stdout"); + assert!( + stdout.contains(&format!("http://127.0.0.1:{port}/auth?key=")), + "the printed URL is the running gateway's own handoff URL: {stdout}" + ); + + let _ = first.kill(); + let _ = first.wait(); + assert!( + !logs.join("gateway.log.1").exists(), + "the handoff never rotated the running gateway's log" + ); + let log = std::fs::read_to_string(logs.join("gateway.log")).expect("read the log"); + assert_eq!( + log.matches("logging to").count(), + 1, + "only the serving instance wrote a startup line: {log}" + ); +} + +/// `--version` and `--help` exit before logging starts: a pre-existing log +/// is left untouched and never rotated. +#[test] +fn version_and_help_never_rotate_the_log() { + let temp = tempfile::tempdir().unwrap(); + let logs = temp.path().join(".promptforge").join("logs"); + std::fs::create_dir_all(&logs).expect("create the logs dir"); + std::fs::write(logs.join("gateway.log"), "the running gateway's log").expect("seed the log"); + for flag in ["--version", "--help"] { + let status = std::process::Command::new(env!("CARGO_BIN_EXE_promptforge-gateway")) + .arg(flag) + .env("USERPROFILE", temp.path()) + .env("HOME", temp.path()) + .env_remove("RUST_LOG") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .expect("the flag invocation exits"); + assert!(status.success(), "{flag} exits successfully: {status}"); + assert_eq!( + std::fs::read_to_string(logs.join("gateway.log")).expect("read the log"), + "the running gateway's log", + "{flag} left the log untouched" + ); + assert!( + !logs.join("gateway.log.1").exists(), + "{flag} rotated no log" + ); + } +} + +/// `diagnostics` prints the JSON report and exits without serving: a +/// pre-existing log is left untouched and unrotated, no connection file is +/// created, and the report names the state dir, the config, the logs, and +/// the connection file with `running: false`. +#[test] +fn diagnostics_reports_without_serving_or_mutating() { + let temp = tempfile::tempdir().unwrap(); + let logs = temp.path().join(".promptforge").join("logs"); + std::fs::create_dir_all(&logs).expect("create the logs dir"); + std::fs::write(logs.join("gateway.log"), "the running gateway's log").expect("seed the log"); + let config = temp.path().join(".promptforge").join("gateway.toml"); + std::fs::write(&config, "config-version = 2\n").expect("seed the profile config"); + + let output = std::process::Command::new(env!("CARGO_BIN_EXE_promptforge-gateway")) + .arg("diagnostics") + .env("USERPROFILE", temp.path()) + .env("HOME", temp.path()) + .env_remove("RUST_LOG") + .output() + .expect("the diagnostics invocation runs"); + assert!( + output.status.success(), + "diagnostics exits successfully: {}", + output.status + ); + let stdout = String::from_utf8(output.stdout).expect("stdout is UTF-8"); + let report: Value = serde_json::from_str(&stdout).expect("the report is JSON"); + assert_eq!( + report["state_dir"].as_str().map(std::path::Path::new), + Some(temp.path().join(".promptforge").as_path()), + "the report names the state dir: {stdout}" + ); + assert_eq!( + report["config"]["path"].as_str().map(std::path::Path::new), + Some(config.as_path()), + "discovery names the profile config: {stdout}" + ); + assert_eq!( + report["config"]["exists"], true, + "the seeded config is reported as existing: {stdout}" + ); + assert_eq!(report["running"], false, "nothing is running"); + assert_eq!( + report["logs"]["current"]["exists"], true, + "the seeded log is reported: {stdout}" + ); + assert_eq!( + report["logs"]["retained"].as_array().unwrap().len(), + 5, + "five retained slots are reported: {stdout}" + ); + assert_eq!(report["connection_file"]["exists"], false); + assert!( + report["version"].as_str().is_some(), + "the report carries the version" + ); + assert!( + !stdout.contains("api_key"), + "the report carries no key material: {stdout}" + ); + + assert_eq!( + std::fs::read_to_string(logs.join("gateway.log")).expect("read the log"), + "the running gateway's log", + "diagnostics left the log untouched" + ); + assert!( + !logs.join("gateway.log.1").exists(), + "diagnostics rotated no log" + ); + assert!( + !temp.path().join(".promptforge/run/gateway.json").exists(), + "diagnostics created no connection file" + ); +} + +/// With a gateway serving, `diagnostics` reports `running: true` - the +/// same already-running detection the handoff path uses - and still never +/// rotates the running gateway's log. +#[test] +fn diagnostics_reports_a_running_gateway_without_rotating_its_log() { + let temp = tempfile::tempdir().unwrap(); + let path = write_config( + &temp, + "config-version = 2\n\n[server]\nbind = \"127.0.0.1:0\"\napi_key = \"test-token\"\n\n\ + [[profile]]\nname = \"main\"\nmodels = []\n" + .to_string(), + ); + let logs = temp.path().join(".promptforge").join("logs"); + let connection = temp + .path() + .join(".promptforge") + .join("run") + .join("gateway.json"); + let mut first = std::process::Command::new(env!("CARGO_BIN_EXE_promptforge-gateway")) + .arg("--config") + .arg(&path) + .arg("--profile") + .arg("main") + .arg("--no-tray") + .env("USERPROFILE", temp.path()) + .env("HOME", temp.path()) + .env_remove("RUST_LOG") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("the gateway spawns"); + let deadline = std::time::Instant::now() + Duration::from_secs(30); + while !connection.is_file() { + assert!( + std::time::Instant::now() < deadline, + "the gateway bound and wrote {}", + connection.display() + ); + std::thread::sleep(Duration::from_millis(50)); + } + + let output = std::process::Command::new(env!("CARGO_BIN_EXE_promptforge-gateway")) + .arg("diagnostics") + .arg("--config") + .arg(&path) + .env("USERPROFILE", temp.path()) + .env("HOME", temp.path()) + .env_remove("RUST_LOG") + .output() + .expect("the diagnostics invocation runs"); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("stdout is UTF-8"); + let report: Value = serde_json::from_str(&stdout).expect("the report is JSON"); + assert_eq!( + report["running"], true, + "the live gateway is reported as running: {stdout}" + ); + assert_eq!(report["connection_file"]["exists"], true); + assert_eq!( + report["config"]["path"].as_str().map(std::path::Path::new), + Some(path.as_path()), + "the explicit --config path is named verbatim: {stdout}" + ); + assert_eq!( + report["config"]["exists"], true, + "the explicit config is reported as existing: {stdout}" + ); + + let _ = first.kill(); + let _ = first.wait(); + assert!( + !logs.join("gateway.log.1").exists(), + "diagnostics never rotated the running gateway's log" + ); + let log = std::fs::read_to_string(logs.join("gateway.log")).expect("read the log"); + assert_eq!( + log.matches("logging to").count(), + 1, + "only the serving instance wrote a startup line: {log}" + ); +} + +/// A fatal boot failure is logged once with its complete source chain and +/// the queue drains before the process exits with a failure status: the +/// chain lands in the log file, not only on stderr. +#[test] +fn a_fatal_boot_error_lands_in_the_log_with_its_chain() { + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("no-such-config.toml"); + let output = std::process::Command::new(env!("CARGO_BIN_EXE_promptforge-gateway")) + .arg("--config") + .arg(&missing) + .arg("--no-tray") + .env("USERPROFILE", temp.path()) + .env("HOME", temp.path()) + .env_remove("RUST_LOG") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .expect("the failing invocation runs"); + assert!( + !output.status.success(), + "a missing explicit config fails the boot: {}", + output.status + ); + + let log = std::fs::read_to_string( + temp.path() + .join(".promptforge") + .join("logs") + .join("gateway.log"), + ) + .expect("the fatal outcome drained to the log file"); + assert_eq!( + log.matches("error:").count(), + 1, + "the fatal error is logged exactly once: {log}" + ); + assert!( + log.contains("caused by:"), + "the complete source chain is logged: {log}" + ); + let fatal = log + .rfind("gateway exiting after a fatal error") + .expect("the fatal terminal record is logged"); + let final_cause = log + .rfind("caused by:") + .expect("the complete source chain is logged"); + assert!( + fatal > final_cause, + "the fatal terminal record follows the complete chain: {log}" + ); + assert!( + log.lines() + .last() + .is_some_and(|line| line.contains("gateway exiting after a fatal error")), + "the fatal terminal record is last: {log}" + ); } /// A config with two profiles over one backend, so a switch from `main` to diff --git a/crates/gateway/tests/it/main.rs b/crates/gateway/tests/it/main.rs index 626bb568..a85f9bd0 100644 --- a/crates/gateway/tests/it/main.rs +++ b/crates/gateway/tests/it/main.rs @@ -34,7 +34,10 @@ mod local; mod profiles; mod progress; mod queue; +#[cfg(feature = "stt")] +mod realtime_stt; mod rerank; mod sidecar; mod surface; +#[cfg(feature = "web-search")] mod web_search; diff --git a/crates/gateway/tests/it/profiles.rs b/crates/gateway/tests/it/profiles.rs index 19d827a9..04a02b5a 100644 --- a/crates/gateway/tests/it/profiles.rs +++ b/crates/gateway/tests/it/profiles.rs @@ -214,13 +214,11 @@ async fn switch_waits_for_an_in_flight_request() { server.shutdown().await; } -/// A request arriving while the switch is parked in its cut-over drain -/// behind a held request does not register against the old routing; it -/// waits for the switch lock and lands on the new table. The in-process -/// test of the same name in the gateway crate pins that the wait is the -/// cut-over's, with the lock observed directly. +/// A held old request finishes before cutover, and requests after the +/// transaction commits use only the newly published routing. The in-process +/// Gateway test observes the cutover lock directly. #[tokio::test] -async fn request_registration_waits_behind_the_switch_lock() { +async fn committed_switch_routes_only_to_the_new_profile() { let (backend, mut arrivals) = slow_fake_backend().await; let (_temp, server) = profile_server(backend).await; let http = reqwest::Client::new(); @@ -247,24 +245,11 @@ async fn request_registration_waits_behind_the_switch_lock() { switch_body.push_str(std::str::from_utf8(&frame).expect("switch SSE is UTF-8")); } - let client = http.clone(); - let url = format!("http://{}/v1/chat/completions", server.addr); - let beta = tokio::spawn(async move { - client - .post(url) - .bearer_auth("test-token") - .json(&serde_json::json!({ - "model": "beta-model", - "messages": [{ "role": "user", "content": "ping" }] - })) - .send() - .await - }); assert!( tokio::time::timeout(Duration::from_millis(100), arrivals.recv()) .await .is_err(), - "a request arriving during drain must not register against old routing" + "the switch itself performs no inference" ); release_alpha.send(()).expect("release alpha request"); @@ -284,6 +269,19 @@ async fn request_registration_waits_behind_the_switch_lock() { Some(&serde_json::json!({"status": "ready", "profile": "beta"})) ); + let client = http.clone(); + let url = format!("http://{}/v1/chat/completions", server.addr); + let beta = tokio::spawn(async move { + client + .post(url) + .bearer_auth("test-token") + .json(&serde_json::json!({ + "model": "beta-model", + "messages": [{ "role": "user", "content": "ping" }] + })) + .send() + .await + }); let release_beta = next_arrival(&mut arrivals).await; release_beta.send(()).expect("release beta request"); assert_eq!( diff --git a/crates/gateway/tests/it/realtime_stt.rs b/crates/gateway/tests/it/realtime_stt.rs new file mode 100644 index 00000000..9da77caa --- /dev/null +++ b/crates/gateway/tests/it/realtime_stt.rs @@ -0,0 +1,567 @@ +//! Mounted Realtime transcription route through the production Gateway wall. + +use std::net::SocketAddr; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +use base64::Engine as _; +use futures_util::{SinkExt as _, StreamExt as _}; +use gateway::{Config, Gateway, ProfilesContext}; +use gateway_stt::SpeechService; +use gateway_stt::test_fixtures::{ + ScriptedDecoder, ScriptedModelFactory, begin_scripted_replacement, scripted_service, +}; +use gateway_stt_engine::test_fixtures::native::require_fixture; +use tokio::net::TcpStream; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::tungstenite::client::IntoClientRequest as _; +use tokio_tungstenite::tungstenite::http::HeaderValue; +use tokio_tungstenite::tungstenite::{Error as SocketError, Message}; + +use crate::support::{PHASE_TIMEOUT, TestServer, send_within}; + +type Socket = WebSocketStream>; + +fn config(strict: bool) -> Config { + Config::from_toml_str(&format!( + "config-version = 2\n\ + [server]\n\ + bind = \"127.0.0.1:0\"\n\ + api_key = \"test-token\"\n\ + trust_loopback = {}\n", + !strict + )) + .expect("Gateway test config parses") +} + +fn speech(interim: &ScriptedDecoder, final_decoder: Option<&ScriptedDecoder>) -> SpeechService { + speech_with_policy(interim, final_decoder, 15, 500) +} + +fn speech_with_policy( + interim: &ScriptedDecoder, + final_decoder: Option<&ScriptedDecoder>, + window_seconds: u64, + interval_ms: u64, +) -> SpeechService { + let factory = final_decoder.map_or_else( + || ScriptedModelFactory::new(interim.clone()), + |final_decoder| { + ScriptedModelFactory::new(interim.clone()).with_final(final_decoder.clone()) + }, + ); + scripted_service(factory, window_seconds, interval_ms).expect("scripted speech starts") +} + +async fn server(strict: bool, service: &SpeechService) -> TestServer { + let gateway = Gateway::new(&config(strict), ProfilesContext::default()) + .with_speech_service(service.clone()); + TestServer::start(gateway).await +} + +fn request( + addr: SocketAddr, + query: &str, + bearer: Option<&str>, + cookie: Option<&str>, + origin: Option<&str>, +) -> tokio_tungstenite::tungstenite::http::Request<()> { + let mut request = format!("ws://{addr}/v1/realtime?{query}") + .into_client_request() + .expect("WebSocket request builds"); + if let Some(bearer) = bearer { + request.headers_mut().insert( + "authorization", + HeaderValue::from_str(&format!("Bearer {bearer}")).expect("bearer is a header"), + ); + } + if let Some(cookie) = cookie { + request.headers_mut().insert( + "cookie", + HeaderValue::from_str(cookie).expect("cookie is a header"), + ); + request + .headers_mut() + .insert("sec-fetch-site", HeaderValue::from_static("same-origin")); + } + if let Some(origin) = origin { + request.headers_mut().insert( + "origin", + HeaderValue::from_str(origin).expect("Origin is a header"), + ); + } + request +} + +async fn connect( + addr: SocketAddr, + bearer: Option<&str>, + cookie: Option<&str>, + origin: Option<&str>, +) -> Socket { + let (socket, response) = tokio::time::timeout( + PHASE_TIMEOUT, + tokio_tungstenite::connect_async(request( + addr, + "intent=transcription", + bearer, + cookie, + origin, + )), + ) + .await + .expect("WebSocket upgrade answers before deadline") + .expect("WebSocket upgrades"); + assert_eq!(response.status(), 101); + socket +} + +async fn rejected( + addr: SocketAddr, + query: &str, + bearer: Option<&str>, + origin: Option<&str>, +) -> u16 { + rejected_request(request(addr, query, bearer, None, origin)).await +} + +async fn rejected_request(request: tokio_tungstenite::tungstenite::http::Request<()>) -> u16 { + match tokio::time::timeout(PHASE_TIMEOUT, tokio_tungstenite::connect_async(request)) + .await + .expect("rejected upgrade answers before deadline") + { + Err(SocketError::Http(response)) => response.status().as_u16(), + other => panic!("expected rejected upgrade, got {other:?}"), + } +} + +async fn receive(socket: &mut Socket) -> serde_json::Value { + receive_within(socket, PHASE_TIMEOUT).await +} + +async fn receive_within(socket: &mut Socket, timeout: Duration) -> serde_json::Value { + let message = tokio::time::timeout(timeout, socket.next()) + .await + .expect("server frame arrives before deadline") + .expect("server keeps the socket open") + .expect("server frame is valid"); + serde_json::from_str( + message + .to_text() + .expect("Realtime server frames are JSON text"), + ) + .expect("Realtime server frame is JSON") +} + +async fn send(socket: &mut Socket, value: serde_json::Value) { + socket + .send(Message::Text(value.to_string().into())) + .await + .expect("client event sends"); +} + +async fn append_audio(socket: &mut Socket, audio: String) { + send( + socket, + serde_json::json!({ + "type": "input_audio_buffer.append", + "audio": audio + }), + ) + .await; +} + +fn audio() -> String { + audio_samples(&vec![8_192; 2_400]) +} + +fn audio_samples(samples: &[i16]) -> String { + let bytes = samples + .iter() + .flat_map(|sample| sample.to_le_bytes()) + .collect::>(); + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +fn native_fixture_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../local/stt-fixtures") +} + +#[test] +fn gateway_native_realtime_keeps_its_workspace_local_fixture_root() { + assert_eq!( + native_fixture_root(), + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../local/stt-fixtures") + ); +} + +fn native_jfk_24khz() -> Vec { + let path = require_fixture( + "PROMPTFORGE_WHISPER_AUDIO", + &native_fixture_root(), + "jfk.wav", + ); + let mut reader = hound::WavReader::open(path).expect("JFK fixture opens"); + let spec = reader.spec(); + assert_eq!(spec.sample_rate, 16_000); + assert_eq!(spec.channels, 1); + let source = reader + .samples::() + .map(|sample| sample.expect("JFK sample decodes")) + .collect::>(); + let mut resampled = Vec::with_capacity(source.len() * 3 / 2); + for pair in source.chunks(2) { + let first = pair[0]; + let second = pair.get(1).copied().unwrap_or(first); + let midpoint = i16::try_from(i32::midpoint(i32::from(first), i32::from(second))) + .expect("the midpoint of two i16 samples remains i16"); + resampled.extend([first, midpoint, second]); + } + resampled +} + +fn native_speech_service() -> SpeechService { + let model = require_fixture( + "PROMPTFORGE_WHISPER_MODEL", + &native_fixture_root(), + "ggml-tiny.en.bin", + ); + let model = model.display().to_string().replace('\\', "/"); + std::thread::spawn(move || { + let cache = tempfile::tempdir().expect("native test cache creates"); + let cache = cache.path().display().to_string().replace('\\', "/"); + let catalog = Config::from_toml_str(&format!( + "config-version = 2\n\ + [server]\nbind = \"127.0.0.1:0\"\napi_key = \"test-token\"\n\ + [local]\ncache_dir = {cache:?}\n\ + [stt]\nwindow_seconds = 4\ninterval_ms = 500\n\ + [[stt_model]]\nname = \"speech\"\nrole = \"interim\"\nsource = {model:?}\nvram_gb = 1.0\n\ + [[stt_model]]\nname = \"speech-final\"\nrole = \"final\"\nsource = {model:?}\nvram_gb = 1.0\n\ + [[profile]]\nname = \"native\"\nmodels = [\"speech\", \"speech-final\"]\n" + )) + .expect("native fixture catalog parses"); + let config = catalog + .select_profile(&gateway_config::ProfileName::parse("native").expect("profile name")) + .expect("native fixture profile selects"); + let service = SpeechService::new(); + let prepared = service.prepare(&config, None).expect("artifacts prepare"); + let replacement = service + .begin_replacement(prepared) + .expect("native engine loads"); + service + .commit_replacement(replacement) + .expect("native generation publishes"); + service + }) + .join() + .expect("native startup thread joins") +} + +fn canonical_sequences() -> serde_json::Value { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("gateway-stt") + .join("tests") + .join("fixtures") + .join("realtime") + .join("valid-sequences.json"); + serde_json::from_slice(&std::fs::read(path).expect("canonical Realtime sequences read")) + .expect("canonical Realtime sequences parse") +} + +fn canonical_message( + fixtures: &serde_json::Value, + sequence: &str, + direction: &str, + event_type: &str, + occurrence: usize, +) -> serde_json::Value { + fixtures[sequence]["events"] + .as_array() + .expect("canonical sequence has events") + .iter() + .filter(|entry| entry["direction"] == direction && entry["message"]["type"] == event_type) + .nth(occurrence) + .unwrap_or_else(|| { + panic!( + "{sequence} has {direction} {event_type} occurrence {}", + occurrence + 1 + ) + })["message"] + .clone() +} + +fn canonical_first_message( + fixtures: &serde_json::Value, + sequence: &str, + direction: &str, + event_type: &str, +) -> serde_json::Value { + canonical_message(fixtures, sequence, direction, event_type, 0) +} + +fn canonical_client( + fixtures: &serde_json::Value, + sequence: &str, + event_type: &str, +) -> serde_json::Value { + canonical_first_message(fixtures, sequence, "client", event_type) +} + +fn canonical_server( + fixtures: &serde_json::Value, + sequence: &str, + event_type: &str, +) -> serde_json::Value { + canonical_first_message(fixtures, sequence, "server", event_type) +} + +async fn expect_type(socket: &mut Socket, expected: &str) -> serde_json::Value { + let event = receive(socket).await; + assert_eq!(event["type"], expected, "{event}"); + event +} + +async fn expect_error( + socket: &mut Socket, + kind: &str, + code: &str, + message: &str, + param: serde_json::Value, + client_event_id: &str, +) -> serde_json::Value { + let event = expect_type(socket, "error").await; + assert_eq!(event["error"]["type"], kind, "{event}"); + assert_eq!(event["error"]["code"], code, "{event}"); + assert_eq!(event["error"]["message"], message, "{event}"); + assert_eq!(event["error"]["param"], param, "{event}"); + assert_eq!(event["error"]["event_id"], client_event_id, "{event}"); + event +} + +async fn assert_stop_reconciles_skipped_range(short_input_samples: usize) { + let interim = ScriptedDecoder::new(); + interim.push_text("last word"); + let final_decoder = ScriptedDecoder::new(); + final_decoder.push_text("corrected first"); + let service = speech_with_policy(&interim, Some(&final_decoder), 15, 50); + let server = server(true, &service).await; + let mut socket = connect(server.addr, Some("test-token"), None, None).await; + expect_type(&mut socket, "session.created").await; + send( + &mut socket, + serde_json::json!({ + "type": "session.update", + "session": { + "type": "transcription", + "include": ["item.input_audio_transcription.hypothesis"] + } + }), + ) + .await; + expect_type(&mut socket, "session.updated").await; + + let before_stop = [ + vec![8_192; 24_000], + vec![0; 72_000], + vec![8_192; short_input_samples], + ] + .concat(); + let hypothesis = final_decoder + .with_next_decode_blocked( + PHASE_TIMEOUT, + || async { + append_audio(&mut socket, audio_samples(&before_stop)).await; + let hypothesis = expect_type( + &mut socket, + "conversation.item.input_audio_transcription.hypothesis", + ) + .await; + (&mut socket, hypothesis) + }, + |(socket, hypothesis)| async { + assert!( + hypothesis["transcript"] + .as_str() + .is_some_and(|text| text.ends_with("last word")) + ); + append_audio(socket, audio_samples(&vec![0; 72_000])).await; + send( + socket, + serde_json::json!({"type": "input_audio_buffer.commit"}), + ) + .await; + expect_type(socket, "input_audio_buffer.committed").await; + expect_type(socket, "conversation.item.created").await; + hypothesis + }, + ) + .await + .expect("the accepted hypothesis is captured while earlier final work is blocked"); + let completed = loop { + let event = receive(&mut socket).await; + if event["type"] == "conversation.item.input_audio_transcription.completed" { + break event; + } + }; + + assert_eq!( + completed["transcript"], + "corrected first last word", + "accepted={hypothesis}, final_lengths={:?}", + final_decoder + .requests() + .iter() + .map(|request| request.samples().len()) + .collect::>() + ); + assert_eq!( + final_decoder.requests().len(), + 1, + "the 300 ms final range and stop-time silence are explicit skips" + ); + + socket.close(None).await.expect("socket closes"); + drop(socket); + server.shutdown().await; +} + +async fn assert_same_range_final_authority(final_text: &str, expected: &str) { + let interim = ScriptedDecoder::new(); + interim.push_text("provisional words"); + let final_decoder = ScriptedDecoder::new(); + final_decoder.push_text(final_text); + let service = speech_with_policy(&interim, Some(&final_decoder), 15, 50); + let server = server(true, &service).await; + let mut socket = connect(server.addr, Some("test-token"), None, None).await; + expect_type(&mut socket, "session.created").await; + send( + &mut socket, + serde_json::json!({ + "type": "session.update", + "session": { + "type": "transcription", + "include": ["item.input_audio_transcription.hypothesis"] + } + }), + ) + .await; + expect_type(&mut socket, "session.updated").await; + + final_decoder + .with_next_decode_blocked( + PHASE_TIMEOUT, + || async { + append_audio(&mut socket, audio_samples(&vec![8_192; 12_000])).await; + let hypothesis = expect_type( + &mut socket, + "conversation.item.input_audio_transcription.hypothesis", + ) + .await; + assert_eq!(hypothesis["transcript"], "provisional words"); + send( + &mut socket, + serde_json::json!({"type": "input_audio_buffer.commit"}), + ) + .await; + expect_type(&mut socket, "input_audio_buffer.committed").await; + expect_type(&mut socket, "conversation.item.created").await; + }, + |()| async {}, + ) + .await + .expect("the exact accepted range reaches blocked authoritative final decoding"); + let completed = expect_type( + &mut socket, + "conversation.item.input_audio_transcription.completed", + ) + .await; + assert_eq!(completed["transcript"], expected); + + socket.close(None).await.expect("socket closes"); + drop(socket); + server.shutdown().await; +} + +fn assert_native_incremental_spans(spans: &[(u64, u64, String)]) { + assert!( + spans.windows(2).all(|pair| pair[0].1 < pair[1].1), + "incremental snapshots advance their accepted audio end" + ); + assert_eq!(spans[0].0, 0); + let normalized = spans + .iter() + .map(|(_, _, transcript)| normalized_words(transcript)) + .collect::>(); + assert_eq!( + spans.iter().map(|span| span.0).collect::>(), + [0, 0, 0, 1_000], + "three growing windows precede the first one-second slide" + ); + assert!( + normalized[1].len() < normalized[2].len() && normalized[2].starts_with(&normalized[1]), + "the fixed-origin JFK hypothesis grows before sliding: {normalized:?}" + ); + assert!( + spans.iter().skip(1).any(|(start, _, _)| *start > 0), + "the packaged native route eventually slides its window origin" + ); + assert!( + normalized[3].starts_with(&normalized[2]), + "sliding snapshots retain prior speech exactly once: {normalized:?}" + ); + assert_eq!( + normalized.last().expect("a final native snapshot exists"), + &["and", "so", "my", "fellow", "americans", "ask", "not"], + "the known JFK overlap is rebased without duplication" + ); + assert!( + spans + .iter() + .all(|(_, _, transcript)| !transcript.is_empty()), + "every emitted native hypothesis carries replacement text" + ); +} + +fn normalized_words(transcript: &str) -> Vec { + transcript + .split(|character: char| !character.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .map(str::to_lowercase) + .collect() +} + +async fn assert_final_speech_route_surface(http: &reqwest::Client, address: SocketAddr) { + let batch = send_within( + http.post(format!("http://{address}/v1/audio/transcriptions")) + .bearer_auth("test-token"), + ) + .await; + assert_ne!( + batch.status(), + reqwest::StatusCode::NOT_FOUND, + "POST /v1/audio/transcriptions remains mounted" + ); + for path in ["/stt", "/stt/capability"] { + let response = send_within( + http.get(format!("http://{address}{path}")) + .bearer_auth("test-token"), + ) + .await; + assert_eq!( + response.status(), + reqwest::StatusCode::NOT_FOUND, + "GET {path} is retired" + ); + } +} + +include!("realtime_stt/authentication.rs"); +include!("realtime_stt/protocol.rs"); +include!("realtime_stt/scheduling.rs"); +include!("realtime_stt/lifecycle.rs"); +include!("realtime_stt/recovery.rs"); +include!("realtime_stt/overload.rs"); +include!("realtime_stt/capacity.rs"); +include!("realtime_stt/canonical_sequence.rs"); diff --git a/crates/gateway/tests/it/realtime_stt/authentication.rs b/crates/gateway/tests/it/realtime_stt/authentication.rs new file mode 100644 index 00000000..4bea6db7 --- /dev/null +++ b/crates/gateway/tests/it/realtime_stt/authentication.rs @@ -0,0 +1,89 @@ +#[tokio::test] +async fn gateway_auth_origin_query_and_final_speech_surfaces_precede_upgrade() { + let service = speech(&ScriptedDecoder::new(), Some(&ScriptedDecoder::new())); + let strict = server(true, &service).await; + + assert_eq!( + rejected( + strict.addr, + "intent=transcription&intent=transcription", + Some("wrong"), + None, + ) + .await, + 401, + "Gateway auth runs before Realtime query validation" + ); + assert_eq!( + rejected( + strict.addr, + "intent=transcription&intent=transcription", + Some("test-token"), + None, + ) + .await, + 400 + ); + assert_eq!( + rejected( + strict.addr, + "intent=transcription", + Some("test-token"), + Some("http://evil.example"), + ) + .await, + 403 + ); + let mut duplicate_origin = request( + strict.addr, + "intent=transcription", + Some("test-token"), + None, + None, + ); + duplicate_origin + .headers_mut() + .append("origin", HeaderValue::from_static("http://localhost:8080")); + duplicate_origin + .headers_mut() + .append("origin", HeaderValue::from_static("http://localhost:8080")); + assert_eq!(rejected_request(duplicate_origin).await, 403); + + for origin in [None, Some("http://localhost:8080")] { + let mut socket = connect(strict.addr, Some("test-token"), None, origin).await; + expect_type(&mut socket, "session.created").await; + socket.close(None).await.expect("socket closes"); + drop(socket); + } + + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("HTTP client builds"); + let handoff = + send_within(http.get(format!("http://{}/auth?key=test-token", strict.addr))).await; + let cookie = handoff + .headers() + .get("set-cookie") + .expect("handoff sets a cookie") + .to_str() + .expect("cookie is text") + .split(';') + .next() + .expect("cookie has a pair") + .to_owned(); + let mut cookie_socket = connect(strict.addr, None, Some(&cookie), None).await; + expect_type(&mut cookie_socket, "session.created").await; + cookie_socket.close(None).await.expect("socket closes"); + drop(cookie_socket); + + assert_final_speech_route_surface(&http, strict.addr).await; + strict.shutdown().await; + + let trusted = server(false, &service).await; + let mut socket = connect(trusted.addr, None, None, None).await; + expect_type(&mut socket, "session.created").await; + socket.close(None).await.expect("socket closes"); + drop(socket); + trusted.shutdown().await; +} diff --git a/crates/gateway/tests/it/realtime_stt/canonical_sequence.rs b/crates/gateway/tests/it/realtime_stt/canonical_sequence.rs new file mode 100644 index 00000000..e2447aa1 --- /dev/null +++ b/crates/gateway/tests/it/realtime_stt/canonical_sequence.rs @@ -0,0 +1,102 @@ +#[tokio::test] +async fn canonical_fixture_drives_hypothesis_completion_and_clear() { + let fixtures = canonical_sequences(); + let interim = ScriptedDecoder::new(); + interim.push_text("Hello"); + interim.push_text("Hello!"); + let final_decoder = ScriptedDecoder::new(); + final_decoder.push_text("Hello"); + let service = speech(&interim, Some(&final_decoder)); + let server = server(true, &service).await; + let mut socket = connect(server.addr, Some("test-token"), None, None).await; + + let created = expect_type(&mut socket, "session.created").await; + assert_eq!( + created["type"], + canonical_server(&fixtures, "first_event_readiness", "session.created")["type"] + ); + send( + &mut socket, + canonical_client(&fixtures, "hypothesis_negotiation", "session.update"), + ) + .await; + expect_type(&mut socket, "session.updated").await; + + let mut hypotheses = Vec::new(); + for _ in 0..2 { + for _ in 0..5 { + let mut append = canonical_client( + &fixtures, + "hypothesis_negotiation", + "input_audio_buffer.append", + ); + append["audio"] = serde_json::json!(audio()); + send(&mut socket, append).await; + } + hypotheses.push( + expect_type( + &mut socket, + "conversation.item.input_audio_transcription.hypothesis", + ) + .await, + ); + } + let first = &hypotheses[0]; + let second = &hypotheses[1]; + assert_eq!(first["revision"], 1); + assert_eq!(first["transcript"], "Hello"); + assert_eq!(second["revision"], 2); + assert_eq!(second["transcript"], "Hello!"); + + send( + &mut socket, + canonical_client( + &fixtures, + "immediate_commit_and_provisional_promotion", + "input_audio_buffer.commit", + ), + ) + .await; + let committed = expect_type(&mut socket, "input_audio_buffer.committed").await; + let item_id = committed["item_id"].clone(); + assert_eq!( + expect_type(&mut socket, "conversation.item.created").await["item"]["id"], + item_id + ); + let completed = expect_type( + &mut socket, + "conversation.item.input_audio_transcription.completed", + ) + .await; + assert_eq!(completed["item_id"], item_id); + assert_eq!( + completed["transcript"], + canonical_server( + &fixtures, + "hypothesis_negotiation", + "conversation.item.input_audio_transcription.completed", + )["transcript"] + ); + + let mut append = canonical_client( + &fixtures, + "clear_retires_only_uncommitted_input", + "input_audio_buffer.append", + ); + append["audio"] = serde_json::json!(audio()); + send(&mut socket, append).await; + send( + &mut socket, + canonical_client( + &fixtures, + "clear_retires_only_uncommitted_input", + "input_audio_buffer.clear", + ), + ) + .await; + expect_type(&mut socket, "input_audio_buffer.cleared").await; + + socket.close(None).await.expect("socket closes"); + drop(socket); + server.shutdown().await; +} diff --git a/crates/gateway/tests/it/realtime_stt/capacity.rs b/crates/gateway/tests/it/realtime_stt/capacity.rs new file mode 100644 index 00000000..eef7b7c4 --- /dev/null +++ b/crates/gateway/tests/it/realtime_stt/capacity.rs @@ -0,0 +1,207 @@ +async fn commit_existing_item( + socket: &mut Socket, + append: &serde_json::Value, + previous: Option<&String>, +) -> String { + for _ in 0..5 { + send(socket, append.clone()).await; + } + send( + socket, + serde_json::json!({"type": "input_audio_buffer.commit"}), + ) + .await; + let committed = expect_type(socket, "input_audio_buffer.committed").await; + let item_id = committed["item_id"] + .as_str() + .expect("committed item has an ID") + .to_owned(); + assert_eq!( + committed["previous_item_id"], + previous.map_or(serde_json::Value::Null, |item| { + serde_json::Value::String(item.clone()) + }), + "{committed}" + ); + let created = expect_type(socket, "conversation.item.created").await; + assert_eq!(created["item"]["id"], item_id, "{created}"); + item_id +} + +async fn expect_existing_completions( + socket: &mut Socket, + existing_items: &[String], + expected_release: &serde_json::Value, +) { + let mut completed_items = Vec::new(); + let mut released_item = None; + for _ in existing_items { + let completed = expect_type( + socket, + "conversation.item.input_audio_transcription.completed", + ) + .await; + if completed["transcript"] == expected_release["transcript"] { + assert_eq!( + completed["usage"]["type"], expected_release["usage"]["type"], + "{completed}" + ); + assert!( + completed["usage"]["seconds"] + .as_f64() + .is_some_and(|seconds| seconds > 0.0), + "{completed}" + ); + released_item = completed["item_id"].as_str().map(str::to_owned); + } + completed_items.push( + completed["item_id"] + .as_str() + .expect("completion has an item ID") + .to_owned(), + ); + } + assert!( + released_item.is_some(), + "the canonical capacity-release completion is observed" + ); + assert!( + existing_items + .iter() + .all(|item| completed_items.contains(item)), + "only the four existing items complete" + ); +} + +async fn expect_retried_item( + socket: &mut Socket, + retry: serde_json::Value, + existing_items: &[String], +) { + send(socket, retry).await; + let retried = expect_type(socket, "input_audio_buffer.committed").await; + let retried_item = retried["item_id"] + .as_str() + .expect("retried commit has an item ID") + .to_owned(); + assert_eq!( + retried["previous_item_id"], + serde_json::Value::String(existing_items.last().expect("four existing items").clone()), + "{retried}" + ); + assert!( + !existing_items.contains(&retried_item), + "retry promotes the preserved provisional input as a new durable item" + ); + let created = expect_type(socket, "conversation.item.created").await; + assert_eq!(created["item"]["id"], retried_item, "{created}"); + let completed = expect_type( + socket, + "conversation.item.input_audio_transcription.completed", + ) + .await; + assert_eq!(completed["item_id"], retried_item, "{completed}"); + assert_eq!(completed["transcript"], "retried canonical input"); +} + +#[tokio::test] +async fn saturated_commit_preserves_the_canonical_input_for_retry() { + let fixtures = canonical_sequences(); + let mut append = canonical_client( + &fixtures, + "saturated_commit_retry", + "input_audio_buffer.append", + ); + append["audio"] = serde_json::json!(audio()); + let commit = canonical_message( + &fixtures, + "saturated_commit_retry", + "client", + "input_audio_buffer.commit", + 0, + ); + let retry = canonical_message( + &fixtures, + "saturated_commit_retry", + "client", + "input_audio_buffer.commit", + 1, + ); + let interim = ScriptedDecoder::new(); + let final_decoder = ScriptedDecoder::new(); + for transcript in [ + "released", + "existing two", + "existing three", + "existing four", + ] { + final_decoder.push_text(transcript); + } + final_decoder.push_text("retried canonical input"); + let service = speech(&interim, Some(&final_decoder)); + let server = server(true, &service).await; + let mut socket = connect(server.addr, Some("test-token"), None, None).await; + expect_type(&mut socket, "session.created").await; + + let (existing_items, saturated, requests_at_saturation) = final_decoder + .with_next_decode_blocked( + PHASE_TIMEOUT, + || async { + let mut existing_items: Vec = Vec::new(); + for _ in 0..4 { + let item_id = + commit_existing_item(&mut socket, &append, existing_items.last()).await; + existing_items.push(item_id); + } + (&mut socket, existing_items) + }, + |(socket, existing_items)| async { + assert_eq!( + final_decoder.requests().len(), + 1, + "the serial final worker is blocked while four items own finalization" + ); + + for _ in 0..5 { + send(socket, append.clone()).await; + } + send(socket, commit).await; + let saturated = expect_type(socket, "error").await; + let requests_at_saturation = final_decoder.requests().len(); + (existing_items, saturated, requests_at_saturation) + }, + ) + .await + .expect("four committed items remain outstanding behind the blocked final worker"); + let expected_error = canonical_server(&fixtures, "saturated_commit_retry", "error"); + for field in ["type", "code", "message", "param", "event_id"] { + assert_eq!( + saturated["error"][field], expected_error["error"][field], + "{field}: {saturated}" + ); + } + assert_eq!( + requests_at_saturation, 1, + "the rejected commit starts no fifth finalization" + ); + + let expected_release = canonical_server( + &fixtures, + "saturated_commit_retry", + "conversation.item.input_audio_transcription.completed", + ); + expect_existing_completions(&mut socket, &existing_items, &expected_release).await; + expect_retried_item(&mut socket, retry, &existing_items).await; + + let final_requests = final_decoder.requests(); + assert_eq!(final_requests.len(), 5); + assert_eq!( + final_requests[4].samples(), + final_requests[0].samples(), + "retry finalizes exactly the same canonical audio as an accepted item" + ); + + socket.close(None).await.expect("socket closes"); + drop(socket); + server.shutdown().await; +} diff --git a/crates/gateway/tests/it/realtime_stt/lifecycle.rs b/crates/gateway/tests/it/realtime_stt/lifecycle.rs new file mode 100644 index 00000000..77e8eb2b --- /dev/null +++ b/crates/gateway/tests/it/realtime_stt/lifecycle.rs @@ -0,0 +1,149 @@ +#[tokio::test] +async fn completion_cadence_reaps_more_than_eight_canceled_interims() { + let interim = ScriptedDecoder::new(); + let service = speech_with_policy(&interim, Some(&ScriptedDecoder::new()), 15, 50); + let server = server(true, &service).await; + let mut socket = connect(server.addr, Some("test-token"), None, None).await; + expect_type(&mut socket, "session.created").await; + + for request_count in 1..=10 { + interim + .with_next_decode_blocked( + PHASE_TIMEOUT, + || async { + for _ in 0..5 { + append_audio(&mut socket, audio()).await; + } + &mut socket + }, + |socket| async { + assert_eq!( + interim.requests().len(), + request_count, + "scheduled interim {request_count} reaches its worker" + ); + send( + socket, + serde_json::json!({"type": "input_audio_buffer.clear"}), + ) + .await; + assert_eq!( + expect_type(socket, "input_audio_buffer.cleared").await["type"], + "input_audio_buffer.cleared", + "completed canceled joins free bounded capacity before cycle {request_count}" + ); + }, + ) + .await + .unwrap_or_else(|| { + panic!("scheduled interim {request_count} reaches the blocked scenario") + }); + let completed = interim.clone(); + assert!( + tokio::task::spawn_blocking(move || { + completed.wait_for_completed(request_count, PHASE_TIMEOUT) + }) + .await + .expect("completion observer joins"), + "underlying worker job {request_count} completes" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } + + socket.close(None).await.expect("socket closes"); + drop(socket); + server.shutdown().await; +} +#[tokio::test] +async fn consumed_boundary_rebases_before_delayed_finalization_completes() { + let interim = ScriptedDecoder::new(); + for transcript in ["first phrase", "second phrase", "second phrase now"] { + interim.push_text(transcript); + } + let final_decoder = ScriptedDecoder::new(); + final_decoder.push_text("revised first"); + let service = speech_with_policy(&interim, Some(&final_decoder), 8, 500); + let server = server(true, &service).await; + let mut socket = connect(server.addr, Some("test-token"), None, None).await; + expect_type(&mut socket, "session.created").await; + send( + &mut socket, + serde_json::json!({ + "type": "session.update", + "session": { + "type": "transcription", + "include": ["item.input_audio_transcription.hypothesis"] + } + }), + ) + .await; + expect_type(&mut socket, "session.updated").await; + + for _ in 0..10 { + append_audio(&mut socket, audio()).await; + } + let first = expect_type( + &mut socket, + "conversation.item.input_audio_transcription.hypothesis", + ) + .await; + assert_eq!(first["transcript"], "first phrase"); + + final_decoder + .with_next_decode_blocked( + PHASE_TIMEOUT, + || async { + append_audio( + &mut socket, + audio_samples(&[vec![0; 72_000], vec![8_192; 12_000]].concat()), + ) + .await; + &mut socket + }, + |socket| async { + let pending = expect_type( + socket, + "conversation.item.input_audio_transcription.hypothesis", + ) + .await; + assert_eq!(pending["transcript"], "first phrase second phrase"); + assert_eq!(pending["finalized"], ""); + }, + ) + .await + .expect("delayed finalization reaches the blocked scenario"); + let finalized = final_decoder.clone(); + assert!( + tokio::task::spawn_blocking(move || finalized.wait_for_completed(1, PHASE_TIMEOUT)) + .await + .expect("finalization completion observer joins") + ); + append_audio(&mut socket, audio()).await; + let revised = expect_type( + &mut socket, + "conversation.item.input_audio_transcription.hypothesis", + ) + .await; + assert_eq!(revised["finalized"], "revised first"); + assert_eq!(revised["transcript"], "revised first second phrase now"); + + socket.close(None).await.expect("socket closes"); + drop(socket); + server.shutdown().await; +} +#[tokio::test] +async fn stop_reconciles_an_accepted_word_from_a_skipped_short_final_range() { + assert_stop_reconciles_skipped_range(7_200).await; +} +#[tokio::test] +async fn stop_reconciles_an_accepted_word_from_a_click_consumed_range() { + assert_stop_reconciles_skipped_range(2_400).await; +} +#[tokio::test] +async fn same_range_divergent_final_text_overrides_the_accepted_hypothesis() { + assert_same_range_final_authority("authoritative words", "authoritative words").await; +} +#[tokio::test] +async fn same_range_decoded_empty_remains_authoritative() { + assert_same_range_final_authority("", "").await; +} diff --git a/crates/gateway/tests/it/realtime_stt/overload.rs b/crates/gateway/tests/it/realtime_stt/overload.rs new file mode 100644 index 00000000..d60a8040 --- /dev/null +++ b/crates/gateway/tests/it/realtime_stt/overload.rs @@ -0,0 +1,65 @@ +#[tokio::test] +async fn mounted_terminal_failures_preserve_their_typed_wire_reason() { + let fixtures = canonical_sequences(); + let canonical_overload = canonical_server( + &fixtures, + "segment_admission_failure", + "conversation.item.input_audio_transcription.failed", + ); + for (overload, kind, code, message) in [ + ( + false, + "server_error", + "precommit_transcription_failed", + "Accurate precommit transcription failed", + ), + ( + true, + "overload_error", + "final_segment_overload", + "The authoritative segment could not be admitted", + ), + ] { + let mut service = speech(&ScriptedDecoder::new(), Some(&ScriptedDecoder::new())); + if overload { + service.overload_realtime_final_segment(); + } else { + service.fail_realtime_precommit(); + } + let server = server(true, &service).await; + let mut socket = connect(server.addr, Some("test-token"), None, None).await; + expect_type(&mut socket, "session.created").await; + send( + &mut socket, + serde_json::json!({ + "type": "input_audio_buffer.append", + "audio": audio() + }), + ) + .await; + send( + &mut socket, + serde_json::json!({"type": "input_audio_buffer.commit"}), + ) + .await; + expect_type(&mut socket, "input_audio_buffer.committed").await; + expect_type(&mut socket, "conversation.item.created").await; + let failed = expect_type( + &mut socket, + "conversation.item.input_audio_transcription.failed", + ) + .await; + assert_eq!(failed["error"]["type"], kind, "{failed}"); + assert_eq!(failed["error"]["code"], code, "{failed}"); + assert_eq!(failed["error"]["message"], message, "{failed}"); + assert!(failed["error"]["param"].is_null(), "{failed}"); + assert!(failed["error"].get("event_id").is_none(), "{failed}"); + if overload { + assert_eq!(failed["error"], canonical_overload["error"]); + } + + socket.close(None).await.expect("socket closes"); + drop(socket); + server.shutdown().await; + } +} diff --git a/crates/gateway/tests/it/realtime_stt/protocol.rs b/crates/gateway/tests/it/realtime_stt/protocol.rs new file mode 100644 index 00000000..a5cc2df4 --- /dev/null +++ b/crates/gateway/tests/it/realtime_stt/protocol.rs @@ -0,0 +1,372 @@ +#[tokio::test] +async fn producer_snapshots_partition_finalized_agreed_and_tentative_text() { + let interim = ScriptedDecoder::new(); + for transcript in [ + "Why is it", + "Why is it", + "Why is this", + "is this working now", + ] { + interim.push_text(transcript); + } + let final_decoder = ScriptedDecoder::new(); + let service = speech_with_policy(&interim, Some(&final_decoder), 1, 500); + let server = server(true, &service).await; + let mut socket = connect(server.addr, Some("test-token"), None, None).await; + + expect_type(&mut socket, "session.created").await; + send( + &mut socket, + serde_json::json!({ + "type": "session.update", + "session": { + "type": "transcription", + "include": ["item.input_audio_transcription.hypothesis"] + } + }), + ) + .await; + expect_type(&mut socket, "session.updated").await; + + let mut hypotheses = Vec::new(); + for _ in 0..4 { + for _ in 0..5 { + append_audio(&mut socket, audio()).await; + } + hypotheses.push( + expect_type( + &mut socket, + "conversation.item.input_audio_transcription.hypothesis", + ) + .await, + ); + } + + assert_eq!(hypotheses[0]["transcript"], "Why is it"); + assert_eq!(hypotheses[1]["agreed"], "Why is it"); + assert_eq!( + hypotheses[2]["transcript"], "Why is this", + "a whole-window revision retracts its former promoted suffix" + ); + assert_eq!(hypotheses[2]["audio_start_ms"], 500); + assert_eq!(hypotheses[2]["audio_end_ms"], 1_500); + assert_eq!( + hypotheses[3]["transcript"], "Why is this working now", + "the sliding window retains only the prefix before explicit overlap" + ); + assert_eq!(hypotheses[3]["audio_start_ms"], 1_000); + assert_eq!(hypotheses[3]["audio_end_ms"], 2_000); + + socket.close(None).await.expect("socket closes"); + drop(socket); + server.shutdown().await; +} +#[tokio::test] +#[ignore = "requires packaged whisper.dll, ggml-tiny.en.bin, and jfk.wav fixtures"] +async fn realtime_stt_native_incremental() { + for (variable, name) in [ + ("PROMPTFORGE_WHISPER_LIBRARY", "whisper.dll"), + ("PROMPTFORGE_WHISPER_MODEL", "ggml-tiny.en.bin"), + ("PROMPTFORGE_WHISPER_AUDIO", "jfk.wav"), + ] { + let _fixture = require_fixture(variable, &native_fixture_root(), name); + } + let service = native_speech_service(); + let server = server(true, &service).await; + let mut socket = connect(server.addr, Some("test-token"), None, None).await; + expect_type(&mut socket, "session.created").await; + send( + &mut socket, + serde_json::json!({ + "type": "session.update", + "session": { + "type": "transcription", + "include": ["item.input_audio_transcription.hypothesis"] + } + }), + ) + .await; + expect_type(&mut socket, "session.updated").await; + + let samples = native_jfk_24khz(); + let mut cursor = 0; + let mut spans = Vec::new(); + for chunk_samples in [48_000, 24_000, 24_000, 24_000] { + let end = (cursor + chunk_samples).min(samples.len()); + append_audio(&mut socket, audio_samples(&samples[cursor..end])).await; + cursor = end; + let event = tokio::time::timeout(Duration::from_secs(90), async { + loop { + let event = receive_within(&mut socket, Duration::from_secs(90)).await; + if event["type"] == "conversation.item.input_audio_transcription.hypothesis" { + return event; + } + } + }) + .await + .expect("native hypothesis arrives before its decode deadline"); + spans.push(( + event["audio_start_ms"] + .as_u64() + .expect("native start offset is unsigned"), + event["audio_end_ms"] + .as_u64() + .expect("native end offset is unsigned"), + event["transcript"] + .as_str() + .expect("native transcript is text") + .to_owned(), + )); + } + assert_native_incremental_spans(&spans); + + socket.close(None).await.expect("socket closes"); + drop(socket); + server.shutdown().await; + tokio::task::spawn_blocking(move || service.shutdown()) + .await + .expect("native shutdown thread joins"); +} +#[tokio::test] +async fn mounted_route_drives_scripted_wire_ownership_errors_and_privacy() { + let interim = ScriptedDecoder::new(); + interim.push_text("provisional transcript"); + interim.push_text("provisional transcript"); + let final_decoder = ScriptedDecoder::new(); + final_decoder.push_text("authoritative transcript"); + let service = speech(&interim, Some(&final_decoder)); + let server = server(true, &service).await; + let mut socket = connect(server.addr, Some("test-token"), None, None).await; + + let created = expect_type(&mut socket, "session.created").await; + assert_eq!(created["session"]["type"], "transcription"); + send( + &mut socket, + serde_json::json!({ + "type": "session.update", + "event_id": "private-client-update", + "session": { + "type": "transcription", + "audio": {"input": {"transcription": {"prompt": "private prompt"}}}, + "include": [] + } + }), + ) + .await; + let updated = expect_type(&mut socket, "session.updated").await; + assert_eq!( + updated["session"]["audio"]["input"]["transcription"]["prompt"], + "private prompt" + ); + + send( + &mut socket, + serde_json::json!({ + "type": "input_audio_buffer.append", + "event_id": "bad-audio", + "audio": 7 + }), + ) + .await; + let error = expect_type(&mut socket, "error").await; + assert_eq!(error["error"]["event_id"], "bad-audio"); + assert!( + !error.to_string().contains(&audio()), + "errors never echo buffered audio" + ); + + for pass in 1..=2 { + for _ in 0..5 { + append_audio(&mut socket, audio()).await; + } + let completed = interim.clone(); + assert!( + tokio::task::spawn_blocking(move || { + completed.wait_for_completed(pass, PHASE_TIMEOUT) + }) + .await + .expect("interim completion observer joins") + ); + } + send( + &mut socket, + serde_json::json!({ + "type": "input_audio_buffer.commit", + "event_id": "commit-one" + }), + ) + .await; + let committed = expect_type(&mut socket, "input_audio_buffer.committed").await; + let item_id = committed["item_id"] + .as_str() + .expect("commit owns an item") + .to_owned(); + assert_eq!(committed["item_id"], item_id); + assert!(committed["previous_item_id"].is_null()); + let item = expect_type(&mut socket, "conversation.item.created").await; + assert_eq!(item["item"]["id"], item_id); + let delta = expect_type( + &mut socket, + "conversation.item.input_audio_transcription.delta", + ) + .await; + assert_eq!(delta["item_id"], item_id); + assert_eq!(delta["delta"], "provisional transcript"); + let complete = expect_type( + &mut socket, + "conversation.item.input_audio_transcription.completed", + ) + .await; + assert_eq!(complete["item_id"], item_id); + assert_eq!(complete["transcript"], "authoritative transcript"); + + let interim_requests = interim.requests(); + assert_eq!(interim_requests.len(), 2); + assert_eq!(interim_requests[0].guidance(), ["private prompt"]); + assert_eq!(final_decoder.requests().len(), 1); + + socket.close(None).await.expect("socket closes"); + drop(socket); + server.shutdown().await; +} +#[tokio::test] +async fn mounted_session_errors_keep_canonical_codes_parameters_and_correlation() { + let interim = ScriptedDecoder::new(); + interim.push_error("scripted interim failure"); + let service = speech(&interim, Some(&ScriptedDecoder::new())); + let server = server(true, &service).await; + let mut socket = connect(server.addr, Some("test-token"), None, None).await; + expect_type(&mut socket, "session.created").await; + + send( + &mut socket, + serde_json::json!({ + "type": "input_audio_buffer.append", + "event_id": "invalid-audio", + "audio": "***" + }), + ) + .await; + expect_error( + &mut socket, + "invalid_request_error", + "invalid_base64_audio", + "Audio must be valid Base64", + serde_json::json!("audio"), + "invalid-audio", + ) + .await; + + for _ in 0..5 { + append_audio(&mut socket, audio()).await; + } + let inference = expect_type(&mut socket, "error").await; + assert_eq!(inference["error"]["type"], "server_error"); + assert_eq!(inference["error"]["code"], "internal_error"); + assert_eq!(inference["error"]["message"], "Transcription failed"); + assert!(inference["error"]["param"].is_null()); + assert!( + inference["error"]["event_id"].is_null(), + "scheduled inference failure is not attributed to one append" + ); + + send( + &mut socket, + serde_json::json!({"type": "input_audio_buffer.clear"}), + ) + .await; + expect_type(&mut socket, "input_audio_buffer.cleared").await; + let short = base64::engine::general_purpose::STANDARD.encode([0_u8, 0]); + send( + &mut socket, + serde_json::json!({ + "type": "input_audio_buffer.append", + "audio": short + }), + ) + .await; + send( + &mut socket, + serde_json::json!({ + "type": "input_audio_buffer.commit", + "event_id": "short-commit" + }), + ) + .await; + expect_error( + &mut socket, + "invalid_request_error", + "audio_too_short", + "A commit requires at least 100 ms of audio", + serde_json::json!("audio"), + "short-commit", + ) + .await; + + socket.close(None).await.expect("socket closes"); + drop(socket); + server.shutdown().await; +} +#[tokio::test] +async fn standard_interims_emit_only_appendable_agreed_deltas() { + let interim = ScriptedDecoder::new(); + for transcript in ["Hello there", "Hello world", "Hello world again"] { + interim.push_text(transcript); + } + let final_decoder = ScriptedDecoder::new(); + final_decoder.push_text("Hello world again"); + let service = speech(&interim, Some(&final_decoder)); + let server = server(true, &service).await; + let mut socket = connect(server.addr, Some("test-token"), None, None).await; + expect_type(&mut socket, "session.created").await; + + for pass in 1..=3 { + for _ in 0..5 { + append_audio(&mut socket, audio()).await; + } + let completed = interim.clone(); + assert!( + tokio::task::spawn_blocking(move || { + completed.wait_for_completed(pass, PHASE_TIMEOUT) + }) + .await + .expect("interim completion observer joins") + ); + } + send( + &mut socket, + serde_json::json!({"type": "input_audio_buffer.commit"}), + ) + .await; + expect_type(&mut socket, "input_audio_buffer.committed").await; + expect_type(&mut socket, "conversation.item.created").await; + let first = expect_type( + &mut socket, + "conversation.item.input_audio_transcription.delta", + ) + .await; + let second = expect_type( + &mut socket, + "conversation.item.input_audio_transcription.delta", + ) + .await; + assert_eq!(first["delta"], "Hello"); + assert_eq!(second["delta"], " world"); + assert_eq!( + format!( + "{}{}", + first["delta"].as_str().expect("first delta is text"), + second["delta"].as_str().expect("second delta is text") + ), + "Hello world" + ); + expect_type( + &mut socket, + "conversation.item.input_audio_transcription.completed", + ) + .await; + + socket.close(None).await.expect("socket closes"); + drop(socket); + server.shutdown().await; +} diff --git a/crates/gateway/tests/it/realtime_stt/recovery.rs b/crates/gateway/tests/it/realtime_stt/recovery.rs new file mode 100644 index 00000000..8b4793ef --- /dev/null +++ b/crates/gateway/tests/it/realtime_stt/recovery.rs @@ -0,0 +1,147 @@ +#[tokio::test] +async fn admission_is_bounded_and_replacement_closes_with_1012() { + let interim = ScriptedDecoder::new(); + let final_decoder = ScriptedDecoder::new(); + final_decoder.push_text("too late"); + let service = speech(&interim, Some(&final_decoder)); + let server = server(true, &service).await; + let mut sockets = Vec::new(); + for _ in 0..8 { + let mut socket = connect(server.addr, Some("test-token"), None, None).await; + expect_type(&mut socket, "session.created").await; + sockets.push(socket); + } + let status = rejected(server.addr, "intent=transcription", Some("test-token"), None).await; + assert_eq!(status, 429); + for mut socket in sockets.drain(1..) { + socket.close(None).await.expect("socket closes"); + } + for _ in 0..5 { + send( + &mut sockets[0], + serde_json::json!({ + "type": "input_audio_buffer.append", + "audio": audio() + }), + ) + .await; + } + let replacement = ScriptedDecoder::new(); + let replacement_final = ScriptedDecoder::new(); + let scenario_service = service.clone(); + let replacement_task = final_decoder + .with_next_decode_blocked( + PHASE_TIMEOUT, + || async { + send( + &mut sockets[0], + serde_json::json!({"type": "input_audio_buffer.commit"}), + ) + .await; + let committed = + expect_type(&mut sockets[0], "input_audio_buffer.committed").await; + let item_id = committed["item_id"].as_str().expect("item ID").to_owned(); + expect_type(&mut sockets[0], "conversation.item.created").await; + (&mut sockets, item_id) + }, + |(sockets, item_id)| async move { + let replacement_service = scenario_service; + let replacement_task = tokio::task::spawn_blocking(move || { + begin_scripted_replacement( + &replacement_service, + ScriptedModelFactory::new(replacement).with_final(replacement_final), + true, + PHASE_TIMEOUT, + ) + }); + let replaced = expect_type( + &mut sockets[0], + "conversation.item.input_audio_transcription.failed", + ) + .await; + assert_eq!(replaced["item_id"], item_id); + assert_eq!(replaced["error"]["code"], "engine_replaced"); + let message = tokio::time::timeout(PHASE_TIMEOUT, sockets[0].next()) + .await + .expect("replacement closes the socket before its deadline") + .expect("socket emits a close frame") + .expect("close frame is valid"); + let Message::Close(Some(close)) = message else { + panic!("replacement emits a close frame, got {message:?}"); + }; + assert_eq!(u16::from(close.code), 1012); + assert_eq!(close.reason, "engine_replaced"); + sockets.clear(); + (replacement_task,) + }, + ) + .await + .expect("the committed item owns one blocked final decode"); + let (replacement_task,) = replacement_task; + let staged = replacement_task + .await + .expect("replacement task joins") + .expect("replacement stages after session ownership drains"); + service + .commit_replacement(staged) + .expect("replacement commits"); + let mut replacement_socket = connect(server.addr, Some("test-token"), None, None).await; + expect_type(&mut replacement_socket, "session.created").await; + replacement_socket + .close(None) + .await + .expect("replacement socket closes"); + drop(replacement_socket); + server.shutdown().await; +} +#[tokio::test] +async fn blocked_server_send_expires_and_releases_admission() { + let interim = ScriptedDecoder::new(); + interim.push_text("blocked transcript"); + let mut service = speech(&interim, Some(&ScriptedDecoder::new())); + service.block_realtime_send_after(8); + let server = server(true, &service).await; + + let mut blocked = connect(server.addr, Some("test-token"), None, None).await; + expect_type(&mut blocked, "session.created").await; + let mut occupants = Vec::new(); + for _ in 0..7 { + let mut socket = connect(server.addr, Some("test-token"), None, None).await; + expect_type(&mut socket, "session.created").await; + occupants.push(socket); + } + send( + &mut blocked, + serde_json::json!({ + "type": "input_audio_buffer.append", + "audio": audio() + }), + ) + .await; + send( + &mut blocked, + serde_json::json!({"type": "input_audio_buffer.commit"}), + ) + .await; + assert_eq!( + rejected( + server.addr, + "intent=transcription", + Some("test-token"), + None + ) + .await, + 429, + "the blocked send initially retains its session" + ); + + tokio::time::sleep(Duration::from_secs(2)).await; + let admitted = connect(server.addr, Some("test-token"), None, None).await; + + drop(admitted); + for mut socket in occupants { + socket.close(None).await.expect("socket closes"); + } + drop(blocked); + server.shutdown().await; +} diff --git a/crates/gateway/tests/it/realtime_stt/scheduling.rs b/crates/gateway/tests/it/realtime_stt/scheduling.rs new file mode 100644 index 00000000..aa2bdb90 --- /dev/null +++ b/crates/gateway/tests/it/realtime_stt/scheduling.rs @@ -0,0 +1,90 @@ +#[tokio::test] +async fn interim_scheduler_enforces_cadence_minimum_silence_and_coalescing() { + let interim = ScriptedDecoder::new(); + interim.push_text("first window"); + interim.push_text("newest window"); + let service = speech_with_policy(&interim, Some(&ScriptedDecoder::new()), 15, 500); + let server = server(true, &service).await; + let mut socket = connect(server.addr, Some("test-token"), None, None).await; + expect_type(&mut socket, "session.created").await; + + for _ in 0..4 { + append_audio(&mut socket, audio()).await; + } + tokio::time::sleep(Duration::from_millis(600)).await; + assert!( + interim.requests().is_empty(), + "sub-500 ms audio never enters the decoder" + ); + + interim + .with_next_decode_blocked( + PHASE_TIMEOUT, + || async { + append_audio(&mut socket, audio()).await; + &mut socket + }, + |socket| async { + for _ in 0..5 { + append_audio(socket, audio()).await; + } + tokio::time::sleep(Duration::from_millis(600)).await; + assert_eq!( + interim.requests().len(), + 1, + "only one interim decode may be in flight" + ); + }, + ) + .await + .expect("the first eligible scheduled decode reaches the blocked scenario"); + let coalesced = interim.clone(); + assert!( + tokio::task::spawn_blocking(move || coalesced.wait_for_requests(2, PHASE_TIMEOUT)) + .await + .expect("coalesced request observer joins"), + "the newest eligible snapshot runs after release" + ); + assert_eq!(interim.requests()[1].samples().len(), 16_000); + + interim + .with_next_decode_blocked( + PHASE_TIMEOUT, + || async { + for _ in 0..5 { + append_audio(&mut socket, audio()).await; + } + &mut socket + }, + |socket| async { + send( + socket, + serde_json::json!({"type": "input_audio_buffer.clear"}), + ) + .await; + expect_type(socket, "input_audio_buffer.cleared").await; + }, + ) + .await + .expect("the canceled interim reaches the blocked scenario"); + let cleaned = interim.clone(); + assert!( + tokio::task::spawn_blocking(move || cleaned.wait_for_completed(3, PHASE_TIMEOUT)) + .await + .expect("canceled worker observer joins"), + "cleared scheduled work releases its underlying worker job" + ); + for _ in 0..5 { + append_audio(&mut socket, audio_samples(&vec![0; 2_400])).await; + } + tokio::time::sleep(Duration::from_millis(600)).await; + assert_eq!( + interim.requests().len(), + 3, + "eligible silent windows are suppressed" + ); + + socket.close(None).await.expect("socket closes"); + drop(socket); + server.shutdown().await; +} diff --git a/crates/gateway/tests/it/support.rs b/crates/gateway/tests/it/support.rs index c93bcb3b..2067caf7 100644 --- a/crates/gateway/tests/it/support.rs +++ b/crates/gateway/tests/it/support.rs @@ -312,6 +312,7 @@ pub(crate) async fn gateway_for(backend: SocketAddr) -> TestServer { } /// A fake Brave Search backend returning five hits on two hosts. +#[cfg(feature = "web-search")] pub(crate) async fn fake_brave() -> SocketAddr { async fn search() -> Json { Json(serde_json::json!({ @@ -330,6 +331,7 @@ pub(crate) async fn fake_brave() -> SocketAddr { } /// Start 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!( r#" diff --git a/crates/gateway/tests/it/surface.rs b/crates/gateway/tests/it/surface.rs index bbda4ac5..1615dd7b 100644 --- a/crates/gateway/tests/it/surface.rs +++ b/crates/gateway/tests/it/surface.rs @@ -121,6 +121,95 @@ async fn a_keyless_loopback_client_reaches_the_inference_and_admin_surfaces() { server.shutdown().await; } +#[cfg(feature = "stt")] +#[tokio::test] +async fn speech_status_is_generic_and_inactive_models_are_not_advertised() { + let server = gateway_for(fake_backend().await).await; + let client = reqwest::Client::new(); + + let status = send_within( + client + .get(format!("http://{}/admin/status", server.addr)) + .bearer_auth("test-token"), + ) + .await + .json::() + .await + .expect("status is JSON"); + assert_eq!( + status["speech"], + serde_json::json!({ + "configured": false, + "ready": false, + "gpu": false, + "generation": null, + }) + ); + + let catalog = send_within( + client + .get(format!("http://{}/v1/models", server.addr)) + .bearer_auth("test-token"), + ) + .await + .json::() + .await + .expect("catalog is JSON"); + assert_eq!( + catalog["data"] + .as_array() + .expect("catalog data") + .iter() + .map(|model| model["id"].as_str().expect("model id")) + .collect::>(), + ["test-model"] + ); + + server.shutdown().await; +} + +#[cfg(not(feature = "stt"))] +#[tokio::test] +async fn featureless_gateway_omits_speech_status_and_models() { + let server = gateway_for(fake_backend().await).await; + let client = reqwest::Client::new(); + + let status = send_within( + client + .get(format!("http://{}/admin/status", server.addr)) + .bearer_auth("test-token"), + ) + .await + .json::() + .await + .expect("status is JSON"); + assert!( + status.get("speech").is_none(), + "featureless status has no speech surface" + ); + + let catalog = send_within( + client + .get(format!("http://{}/v1/models", server.addr)) + .bearer_auth("test-token"), + ) + .await + .json::() + .await + .expect("catalog is JSON"); + assert_eq!( + catalog["data"] + .as_array() + .expect("catalog data") + .iter() + .map(|model| model["id"].as_str().expect("model id")) + .collect::>(), + ["test-model"] + ); + + server.shutdown().await; +} + #[tokio::test] async fn trust_loopback_false_refuses_the_keyless_loopback_client() { let server = strict_gateway_for(fake_backend().await).await; diff --git a/crates/promptforge-agent/src/agent.rs b/crates/promptforge-agent/src/agent.rs index e8d87f33..608459e0 100644 --- a/crates/promptforge-agent/src/agent.rs +++ b/crates/promptforge-agent/src/agent.rs @@ -627,30 +627,39 @@ async fn dispatch_infer( /// never on `finish_reason`. The model client fails the batch when /// `length` or `content_filter` truncates a tool-call round, and that /// failure rides back as this call's answer. +/// A binding that is absent when dispatch begins reports a failed turn +/// before its call-site error resumes into Lua, so a surrounding `pcall` +/// cannot hide the operator-visible boundary failure. async fn dispatch_chat( run: &AgentRun<'_>, messages: &serde_json::Value, model: Option, tools: &[String], ) -> Result { + let missing_binding = |message: String| { + run.observer + .observe(run.execution, run.name, detail::MODEL_TURN_FAILED); + AgentError::Program { + message, + source: None, + } + }; let binding = match model { Some(name) => ModelView::binding(&run.model_view, &name) .map_err(|error| AgentError::Program { message: error.to_string(), source: Some(Box::new(error)), })? - .ok_or_else(|| AgentError::Program { - message: format!("model {name:?} is not in this agent's catalog"), - source: None, + .ok_or_else(|| { + missing_binding(format!("model {name:?} is not in this agent's catalog")) })?, None => { resolve_model_binding(&run.model_view, &run.vm.model_runtime)?.ok_or_else(|| { - AgentError::Program { - message: "no model is selected: pass opts.model or call models.use(...) \ - before models.chat" + missing_binding( + "no model is selected: pass opts.model or call models.use(...) \ + before models.chat" .to_owned(), - source: None, - } + ) })? } }; diff --git a/crates/promptforge-agent/src/tests.rs b/crates/promptforge-agent/src/tests.rs index a53e0099..1575f61f 100644 --- a/crates/promptforge-agent/src/tests.rs +++ b/crates/promptforge-agent/src/tests.rs @@ -452,13 +452,19 @@ struct RecordedReply { /// can assert each fires exactly once with its model attribution. #[derive(Default)] struct ContentRecorder { + observations: Mutex>, replies: Mutex>, batches: Mutex)>>, thinking: Mutex>, } impl Observer for ContentRecorder { - fn observe(&self, _execution: &str, _section: &str, _event: Observation) {} + fn observe(&self, _execution: &str, _section: &str, event: Observation) { + self.observations + .lock() + .expect("the observation log must not be poisoned") + .push(event); + } fn on_assistant_reply( &self, @@ -783,6 +789,57 @@ store.write('err.txt', err) assert_eq!(run.gateway.call_count(), 1); } +#[tokio::test] +async fn a_missing_chat_binding_reports_one_failed_turn_before_lua_pcall_resumes() { + let recorder = Arc::new(ContentRecorder::default()); + let mut config = config_with(Arc::clone(&recorder) as Arc); + config.ui = Some(Arc::new( + || json!({ "selected_model": serde_json::Value::Null }), + )); + let run = run_over_fixture( + r#" +local ok, err = pcall(function() + return models.chat( + { { role = "user", content = "not sent" } }, + { model = ui().selected_model } + ) +end) +store.write('ok.txt', tostring(ok)) +store.write('err.txt', err) +"#, + vec![text_body("fixture-model", "never fetched", "stop")], + no_tools(), + config, + ) + .await; + run.result + .as_ref() + .expect("the program catches the missing binding"); + assert_eq!(run.read("ok.txt"), "false"); + assert!( + run.read("err.txt").contains("no model is selected"), + "the call-site error tells the program why no request ran: {}", + run.read("err.txt") + ); + assert_eq!( + run.gateway.call_count(), + 0, + "a missing binding fails before any live model request" + ); + let observations = recorder + .observations + .lock() + .expect("the observation log is intact"); + assert_eq!( + observations + .iter() + .filter(|event| matches!(event, Observation::ModelTurnFailed)) + .count(), + 1, + "the failed boundary is observed exactly once before pcall recovers" + ); +} + #[tokio::test] async fn opts_tools_control_the_advertised_set_and_default_to_none() { let (echo, _) = fixture_tool("echo"); diff --git a/crates/shared-loopback/AGENTS.md b/crates/shared-loopback/AGENTS.md index bc15056a..962c0b28 100644 --- a/crates/shared-loopback/AGENTS.md +++ b/crates/shared-loopback/AGENTS.md @@ -1,8 +1,9 @@ # shared-loopback -The single shared loopback wall for the gateway: two middlewares, one per signal. `require_loopback` refuses non-loopback peers before auth; `require_loopback_host` refuses authorities that are not the bound loopback socket (DNS-rebinding defense). +Shared loopback trust-boundary checks for the Gateway and Workshop products. -- This crate is the only loopback check for admin config and config-ui SPA routes, and the only host-authority check for the gateway's loopback-bound surface; never reimplement either check in gateway or config-ui - all three must call through here so the wall cannot drift. +- `require_loopback` is the only peer check for Gateway admin config and config-ui SPA routes, and `require_loopback_host` is the only host-authority check for the Gateway's loopback-bound surface; never reimplement either check in Gateway or config-ui. - Fail closed: a request missing `ConnectInfo` is refused as non-loopback, never admitted on a wiring fault; the server must start with `into_make_service_with_connect_info::()`. A request naming no authority (no URI authority, no `Host` header) is refused by the host check the same way. - The host check enforces only while the bound address is loopback; a non-loopback bind passes every authority, so a LAN server keeps serving its network. +- `gateway_loopback_origin_allowed` and `workshop_same_origin_authority_allowed` are separately named, fail-closed predicates with distinct policies; never merge or share their policy semantics. - Stay tiny: axum is the only dependency so headless gateway builds can take the wall without pulling config-ui or embedded-asset machinery. diff --git a/crates/shared-loopback/src/lib.rs b/crates/shared-loopback/src/lib.rs index dca82abb..820995db 100644 --- a/crates/shared-loopback/src/lib.rs +++ b/crates/shared-loopback/src/lib.rs @@ -1,4 +1,4 @@ -//! The shared loopback wall for the PromptForge gateway's config surface. +//! Shared loopback request checks for PromptForge servers. //! //! Two middlewares form the wall. [`require_loopback`] refuses any request //! whose peer address is not loopback. [`require_loopback_host`] refuses @@ -11,12 +11,18 @@ //! axum is its only dependency - because the gateway needs the wall in //! every build, including headless builds that never compile the //! config-ui crate and its embedded-asset machinery. +//! +//! WebSocket Origin policy stays explicit and product-specific: +//! [`gateway_loopback_origin_allowed`] admits native clients or HTTP loopback +//! origins, while [`workshop_same_origin_authority_allowed`] requires browser +//! origins to match the Workshop request authority. use std::net::SocketAddr; 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}; @@ -144,6 +150,106 @@ fn bare_host(bound: SocketAddr) -> String { } } +/// 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; @@ -355,4 +461,88 @@ mod tests { "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}" + ); + } + } } diff --git a/crates/shared-progress/src/event.rs b/crates/shared-progress/src/event.rs index bd52e8fb..4ac80cac 100644 --- a/crates/shared-progress/src/event.rs +++ b/crates/shared-progress/src/event.rs @@ -44,22 +44,23 @@ impl fmt::Display for OperationId { } } -/// One progress observation emitted by a leaf of an operation tree. +/// One progress or lifecycle observation emitted by an operation tree. /// /// Intermediate (`Updated`) events are lossy: handles coalesce them and slow /// receivers drop them. Terminal (`Finished`) events are never coalesced, and -/// consumers detect completion only from `Finished`, never from a fraction -/// reaching 1.0. +/// consumers detect leaf completion only from `Finished`, never from a +/// fraction reaching 1.0. `OperationFinished` marks tree detachment after its +/// final leaf event. #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[non_exhaustive] pub struct ProgressEvent { - /// The operation tree the leaf belongs to. + /// The operation tree the observation belongs to. pub operation: OperationId, - /// Hierarchical leaf id within the operation, for example - /// `local-models/ggml-large-v3/download`. + /// Hierarchical leaf id within the operation, or empty for the + /// operation-level terminal event. pub path: String, - /// Human-readable leaf label. + /// Human-readable leaf label, or empty for the operation-level event. pub label: String, /// What the leaf reports. pub state: EventState, @@ -82,6 +83,18 @@ impl ProgressEvent { state, } } + + /// Creates the terminal lifecycle event emitted when an operation tree + /// detaches from its source hub. + #[must_use] + pub(crate) fn operation_finished(operation: OperationId) -> Self { + Self { + operation, + path: String::new(), + label: String::new(), + state: EventState::OperationFinished, + } + } } /// The kind of observation a [`ProgressEvent`] carries. @@ -107,6 +120,10 @@ pub enum EventState { /// Whether the leaf's work succeeded. ok: bool, }, + /// The complete operation tree detached from its source hub. This + /// lifecycle event follows every leaf event and lets remote importers + /// release operation ownership without closing a process-lifetime stream. + OperationFinished, } #[cfg(test)] @@ -131,6 +148,7 @@ mod serde_tests { EventState::Begun { weight: 2.5 }, EventState::Updated { fraction: 0.25 }, EventState::Finished { ok: false }, + EventState::OperationFinished, ] { let event = ProgressEvent::new(OperationId::next(), "op/leaf", "leaf", state); let json = serde_json::to_string(&event).expect("the event serializes"); diff --git a/crates/shared-progress/src/hub.rs b/crates/shared-progress/src/hub.rs index 73c56209..2068dfb0 100644 --- a/crates/shared-progress/src/hub.rs +++ b/crates/shared-progress/src/hub.rs @@ -143,6 +143,11 @@ mod tests { let leaf = tree.register("leaf", 1.0); assert!(rx.try_recv().is_ok(), "register emits Begun"); drop(tree); + let terminal = rx.try_recv().expect("tree drop emits operation completion"); + assert!(matches!( + terminal.state, + crate::event::EventState::OperationFinished + )); leaf.set_fraction(1.0); assert!( rx.try_recv().is_err(), diff --git a/crates/shared-progress/src/remote.rs b/crates/shared-progress/src/remote.rs index 763b69f0..ee16dd45 100644 --- a/crates/shared-progress/src/remote.rs +++ b/crates/shared-progress/src/remote.rs @@ -84,6 +84,9 @@ impl RemoteOperation { /// assert_eq!(hub.snapshot()[0].nodes[0].fraction, 1.0); /// ``` pub fn apply(&self, event: &ProgressEvent) { + if matches!(event.state, EventState::OperationFinished) { + return; + } let (slot, node) = self.state.ensure_remote(&event.path, &event.label); match event.state { EventState::Begun { weight } => { @@ -101,12 +104,14 @@ impl RemoteOperation { EventState::Finished { ok } => { self.state.finish(&node, ok); } + EventState::OperationFinished => unreachable!("handled before creating a leaf"), } } } impl Drop for RemoteOperation { fn drop(&mut self) { + self.state.finish_operation(); self.state.retire(); self.hub.detach(self.state.operation()); } diff --git a/crates/shared-progress/src/tree.rs b/crates/shared-progress/src/tree.rs index facac6e8..ede1ee5a 100644 --- a/crates/shared-progress/src/tree.rs +++ b/crates/shared-progress/src/tree.rs @@ -126,6 +126,18 @@ impl TreeState { self.live.store(false, Ordering::Relaxed); } + /// Emits the operation-level terminal signal before the tree detaches. + pub(crate) fn finish_operation(&self) { + if !self.live.load(Ordering::Relaxed) { + return; + } + let event = ProgressEvent::operation_finished(self.operation); + tracing::trace!(operation = %self.operation, "progress operation finished"); + // An absent or lagging receiver is not an error. This terminal event + // is never coalesced at the source, like a leaf's Finished event. + let _ = self.events.send(event); + } + fn emit(&self, node: &Node, state: EventState) { if !self.live.load(Ordering::Relaxed) { return; @@ -448,6 +460,7 @@ impl ProgressTree { impl Drop for ProgressTree { fn drop(&mut self) { + self.state.finish_operation(); self.state.retire(); self.hub.detach(self.state.operation()); } diff --git a/crates/shared-sidecar/README.md b/crates/shared-sidecar/README.md index 139795ea..24d885e2 100644 --- a/crates/shared-sidecar/README.md +++ b/crates/shared-sidecar/README.md @@ -2,11 +2,12 @@ [![License](https://img.shields.io/badge/license-BSL--1.0-blue.svg)](../../LICENSE) -The shared sidecar discovery seam for PromptForge: the `gateway.json` connection file the gateway writes after a successful bind, Jupyter-style - port, bearer key, pid, boot epoch, version, start time - plus everything a reader needs to attach to a running gateway instead of launching a second one: validation, stale detection (live pid + gateway process image + health answer + accepted key) with stale-file cleanup, the `gateway.json.lock` launch-race lock with loser-attaches-to-winner semantics, and the raw-`TcpStream` health wait. Synchronous and runtime-agnostic: no tokio, axum, or reqwest, so the gateway's lean builds and the workshop readers share one contract. +The shared sidecar discovery seam for PromptForge: the `gateway.json` connection file the gateway writes after a successful bind, Jupyter-style - port, bearer key, pid, boot epoch, version, start time - plus everything a reader needs to attach to a running gateway instead of launching a second one: validation, stale detection (one stable OS process boot bracketing same-socket health and bearer proofs) with stale-file cleanup, the `gateway.json.lock` launch-race lock with loser-attaches-to-winner semantics, and the raw-`TcpStream` health wait. Synchronous and runtime-agnostic: no tokio, axum, or reqwest, so the gateway's lean builds and the workshop readers share one contract. ## Public surface -- `ConnectionFile` - the `gateway.json` document, with `read`, `write_to` (atomic, owner-only: mode `0600` on Unix, best-effort via the user profile's ACL on Windows), and `remove_if_mine` for clean shutdown. +- `ConnectionFile` - the `gateway.json` document, with `read`, `write_to` (atomic, owner-only: mode `0600` on Unix, best-effort via the user profile's ACL on Windows), and `remove_if_mine` for clean shutdown; debug output redacts bearer and untrusted string metadata. +- `ValidatedConnection` - an unforgeable point-in-time live-connection capability created only after one unchanged OS process boot brackets same-socket health and bearer acceptance checks; external test fixtures cannot choose the accepted image, and debug output redacts bearer and untrusted string metadata. - `resolve` - stale detection: attach parameters for a live gateway, or stale-file cleanup plus the reason. - `launch_or_attach` - the launch-race lock: the winner launches, losers attach to the winner. - `wait_for_health` - poll `GET /health` until it answers 200 or the timeout elapses. diff --git a/crates/shared-sidecar/src/file.rs b/crates/shared-sidecar/src/file.rs index fb4f61b8..c9425cca 100644 --- a/crates/shared-sidecar/src/file.rs +++ b/crates/shared-sidecar/src/file.rs @@ -1,6 +1,7 @@ //! The `gateway.json` connection-file type: what the gateway writes after //! a successful bind and what readers validate before attaching. +use std::fmt; use std::fs; use std::io; use std::path::Path; @@ -16,7 +17,7 @@ use crate::paths::connection_file_path; /// /// Readers must tolerate unknown fields: a newer gateway may write fields /// an older reader does not know, and serde ignores them. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ConnectionFile { /// The gateway's bound port; readers connect to `127.0.0.1:{port}`. pub port: u16, @@ -32,7 +33,26 @@ pub struct ConnectionFile { pub started_at: String, } +impl fmt::Debug for ConnectionFile { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ConnectionFile") + .field("port", &self.port) + .field("api_key", &"[REDACTED]") + .field("pid", &self.pid) + .field("epoch", &self.epoch) + .field("version", &"[REDACTED]") + .field("started_at", &"[REDACTED]") + .finish() + } +} + impl ConnectionFile { + /// Whether the connection names one particular Gateway boot. + pub(crate) fn has_boot_identity(&self) -> bool { + self.epoch != 0 && !self.started_at.trim().is_empty() + } + /// The reason the file fails validation, or `None` when it is valid. /// /// Validation covers the fields attach depends on: a real port, a @@ -45,6 +65,9 @@ impl ConnectionFile { if self.api_key.is_empty() { return Some("api_key must not be empty"); } + if self.api_key.contains(['\r', '\n']) { + return Some("api_key must not contain line breaks"); + } if self.pid == 0 { return Some("pid must not be 0"); } @@ -177,6 +200,26 @@ mod tests { assert_eq!(file, back); } + #[test] + fn debug_redacts_bearer_and_untrusted_metadata() { + let secret = "capability-secret"; + let mut file = valid_file(); + file.api_key = secret.to_owned(); + file.version = format!("version-{secret}\r\n"); + file.started_at = format!("started-{secret}\t"); + + let debug = format!("{file:?}"); + assert!(!debug.contains(secret), "debug output redacts the bearer"); + assert!( + !debug.contains(['\r', '\n', '\t']), + "untrusted metadata cannot inject debug output" + ); + assert!( + debug.contains(&file.port.to_string()), + "the endpoint remains visible" + ); + } + #[test] fn unknown_fields_are_tolerated_for_forward_compatibility() { let json = r#"{"port":8081,"api_key":"k","pid":1,"epoch":0,"version":"0","started_at":"","future":true}"#; @@ -185,7 +228,7 @@ mod tests { } #[test] - fn validation_rejects_a_zero_port_empty_key_and_zero_pid() { + fn validation_rejects_invalid_attach_fields() { let mut file = valid_file(); assert_eq!(file.validation_error(), None); file.port = 0; @@ -194,6 +237,12 @@ mod tests { file.api_key.clear(); assert_eq!(file.validation_error(), Some("api_key must not be empty")); file = valid_file(); + file.api_key = "key\r\ninjected: value".to_owned(); + assert_eq!( + file.validation_error(), + Some("api_key must not contain line breaks") + ); + file = valid_file(); file.pid = 0; assert_eq!(file.validation_error(), Some("pid must not be 0")); } diff --git a/crates/shared-sidecar/src/health.rs b/crates/shared-sidecar/src/health.rs index 712c128d..7052aa87 100644 --- a/crates/shared-sidecar/src/health.rs +++ b/crates/shared-sidecar/src/health.rs @@ -1,4 +1,4 @@ -//! Readiness and key probes: raw loopback HTTP/1.0 over +//! Readiness and key probes: raw loopback HTTP/1.x over //! `std::net::TcpStream`, enough to read a status line, with no HTTP //! client dependency. //! @@ -17,6 +17,8 @@ const RETRY_INTERVAL: Duration = Duration::from_millis(25); /// Per-attempt connect and read timeout, so one hung attempt cannot eat /// the whole budget. const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(2); +/// Maximum accepted response head for the two-request validation proof. +const RESPONSE_HEAD_LIMIT: usize = 16 * 1024; /// A failure of [`wait_for_health`]. #[derive(Debug, thiserror::Error)] @@ -65,6 +67,7 @@ pub enum ProbeError { } /// The outcome of one bearer-key probe. +#[cfg(test)] #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum KeyProbe { /// A 2xx answer: the key is accepted. @@ -75,6 +78,194 @@ pub(crate) enum KeyProbe { Unreachable, } +/// The combined readiness and authority proof for one connection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ConnectionProbe { + /// Health answered and the bearer was accepted. + Accepted, + /// Health failed, including a bearer probe that became unreachable. + HealthFailed, + /// Health answered but the bearer was rejected. + KeyRejected, +} + +/// Proves that one address is healthy and accepts the presented bearer. +/// Both requests use one TCP connection, so authority cannot come from a +/// listener that replaced the endpoint after the health response. +pub(crate) fn probe_connection( + address: &str, + bearer_path: &str, + bearer: &str, + health_budget: Duration, +) -> ConnectionProbe { + let deadline = Instant::now() + health_budget; + loop { + match probe_connection_once(address, bearer_path, bearer) { + ConnectionAttempt::Accepted => return ConnectionProbe::Accepted, + ConnectionAttempt::KeyRejected => return ConnectionProbe::KeyRejected, + ConnectionAttempt::ProofInterrupted => return ConnectionProbe::HealthFailed, + ConnectionAttempt::HealthFailed if Instant::now() >= deadline => { + return ConnectionProbe::HealthFailed; + } + ConnectionAttempt::HealthFailed => std::thread::sleep(RETRY_INTERVAL), + } + } +} + +/// One coherent proof attempt over one TCP connection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConnectionAttempt { + Accepted, + HealthFailed, + KeyRejected, + ProofInterrupted, +} + +/// Checks health and bearer acceptance over one socket. Once health has +/// succeeded, any socket loss fails the proof instead of reconnecting to a +/// potentially different endpoint. +fn probe_connection_once(address: &str, bearer_path: &str, bearer: &str) -> ConnectionAttempt { + let Ok(mut stream) = TcpStream::connect(address) else { + return ConnectionAttempt::HealthFailed; + }; + if configure_stream(&stream).is_err() { + return ConnectionAttempt::HealthFailed; + } + if write_request(&mut stream, address, "/health", None, false).is_err() { + return ConnectionAttempt::HealthFailed; + } + let Ok(health_head) = read_framed_response_head(&mut stream) else { + return ConnectionAttempt::HealthFailed; + }; + if response_status(&health_head) != Some(200) { + return ConnectionAttempt::HealthFailed; + } + if write_request(&mut stream, address, bearer_path, Some(bearer), true).is_err() { + return ConnectionAttempt::ProofInterrupted; + } + let Ok(bearer_head) = read_framed_response_head(&mut stream) else { + return ConnectionAttempt::ProofInterrupted; + }; + if response_status(&bearer_head).is_some_and(|code| (200..300).contains(&code)) { + ConnectionAttempt::Accepted + } else { + ConnectionAttempt::KeyRejected + } +} + +/// Applies the fixed per-attempt read and write budgets. +fn configure_stream(stream: &TcpStream) -> Result<(), ProbeError> { + stream + .set_read_timeout(Some(ATTEMPT_TIMEOUT)) + .map_err(|source| ProbeError::Io { + operation: "configure the read timeout", + source, + })?; + stream + .set_write_timeout(Some(ATTEMPT_TIMEOUT)) + .map_err(|source| ProbeError::Io { + operation: "configure the write timeout", + source, + }) +} + +/// Writes one GET request, retaining or closing the connection as directed. +fn write_request( + stream: &mut TcpStream, + address: &str, + path: &str, + bearer: Option<&str>, + close: bool, +) -> Result<(), ProbeError> { + let connection = if close { "close" } else { "keep-alive" }; + let mut request = + format!("GET {path} HTTP/1.1\r\nHost: {address}\r\nConnection: {connection}\r\n"); + if let Some(key) = bearer { + request.push_str("Authorization: Bearer "); + request.push_str(key); + request.push_str("\r\n"); + } + request.push_str("\r\n"); + stream + .write_all(request.as_bytes()) + .map_err(|source| ProbeError::Io { + operation: "write the validation request", + source, + }) +} + +/// Reads one response head and drains its fixed-length body so the next +/// response starts at a framing boundary on the same socket. +fn read_framed_response_head(stream: &mut TcpStream) -> Result { + let mut response = Vec::with_capacity(512); + let header_end = loop { + if let Some(end) = response.windows(4).position(|bytes| bytes == b"\r\n\r\n") { + break end + 4; + } + if response.len() >= RESPONSE_HEAD_LIMIT { + return Err(ProbeError::UnexpectedStatus { + status_line: "".to_owned(), + }); + } + let mut buffer = [0_u8; 512]; + let read = stream.read(&mut buffer).map_err(|source| ProbeError::Io { + operation: "read the validation response", + source, + })?; + if read == 0 { + return Err(ProbeError::Io { + operation: "read the validation response", + source: std::io::Error::from(std::io::ErrorKind::UnexpectedEof), + }); + } + response.extend_from_slice(&buffer[..read]); + }; + let head = String::from_utf8_lossy(&response[..header_end]).into_owned(); + let content_length = + response_content_length(&head).ok_or_else(|| ProbeError::UnexpectedStatus { + status_line: "".to_owned(), + })?; + let body_already_read = response.len() - header_end; + if body_already_read < content_length { + let mut remaining = content_length - body_already_read; + let mut buffer = [0_u8; 512]; + while remaining > 0 { + let chunk_len = remaining.min(buffer.len()); + let read = stream + .read(&mut buffer[..chunk_len]) + .map_err(|source| ProbeError::Io { + operation: "read the validation response body", + source, + })?; + if read == 0 { + return Err(ProbeError::Io { + operation: "read the validation response body", + source: std::io::Error::from(std::io::ErrorKind::UnexpectedEof), + }); + } + remaining -= read; + } + } + Ok(head) +} + +/// Parses a decimal Content-Length from a response head. +fn response_content_length(head: &str) -> Option { + head.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse().ok()) + .flatten() + }) +} + +/// Parses the three-digit response status. +fn response_status(head: &str) -> Option { + head.split_whitespace() + .nth(1) + .and_then(|code| code.parse::().ok()) +} + /// Polls `GET {base_url}/health` until it answers 200 or `timeout` /// elapses. /// @@ -130,6 +321,7 @@ pub(crate) fn probe_health(address: &str) -> Result<(), ProbeError> { /// Issues one `GET {path}` presenting `api_key` as the bearer token and /// classifies the answer. +#[cfg(test)] pub(crate) fn probe_bearer(address: &str, path: &str, api_key: &str) -> KeyProbe { match request_head(address, "GET", path, Some(api_key)) { Ok(head) => { @@ -161,18 +353,7 @@ pub(crate) fn request_head( operation: "connect", source, })?; - stream - .set_read_timeout(Some(ATTEMPT_TIMEOUT)) - .map_err(|source| ProbeError::Io { - operation: "configure the read timeout", - source, - })?; - stream - .set_write_timeout(Some(ATTEMPT_TIMEOUT)) - .map_err(|source| ProbeError::Io { - operation: "configure the write timeout", - source, - })?; + configure_stream(&stream)?; let mut request = format!("{method} {path} HTTP/1.0\r\nHost: {address}\r\n"); if let Some(key) = bearer { request.push_str("Authorization: Bearer "); @@ -327,4 +508,75 @@ mod tests { KeyProbe::Unreachable ); } + + #[test] + fn connection_proof_cannot_mix_health_and_bearer_across_endpoints() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind replacement fixture"); + let address = listener + .local_addr() + .expect("replacement fixture address") + .to_string(); + let (accepted, received) = mpsc::channel(); + std::thread::spawn(move || { + let (mut health, _) = listener.accept().expect("accept health probe"); + let mut buffer = [0_u8; 1024]; + let read = health.read(&mut buffer).expect("read health probe"); + assert!( + String::from_utf8_lossy(&buffer[..read]).starts_with("GET /health "), + "the first endpoint receives health" + ); + health + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}") + .expect("answer health"); + drop(health); + + listener + .set_nonblocking(true) + .expect("make replacement observation bounded"); + let deadline = Instant::now() + Duration::from_millis(500); + let mut connection_count = 1; + while Instant::now() < deadline { + match listener.accept() { + Ok((mut bearer, _)) => { + connection_count += 1; + let read = bearer.read(&mut buffer).expect("read bearer probe"); + assert!( + String::from_utf8_lossy(&buffer[..read]) + .contains("Authorization: Bearer accepted\r\n"), + "the replacement endpoint accepts the bearer" + ); + bearer + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}") + .expect("answer bearer"); + break; + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(5)); + } + Err(error) => panic!("observe replacement connection: {error}"), + } + } + accepted + .send(connection_count) + .expect("report accepted connections"); + }); + + assert_eq!( + probe_connection( + &address, + "/v1/models", + "accepted", + Duration::from_millis(250) + ), + ConnectionProbe::HealthFailed, + "one capability cannot combine health from one socket with authority from another" + ); + assert_eq!( + received + .recv_timeout(Duration::from_secs(2)) + .expect("fixture reports its connection count"), + 1, + "validation never reconnects after health succeeds" + ); + } } diff --git a/crates/shared-sidecar/src/lib.rs b/crates/shared-sidecar/src/lib.rs index 62ad9904..aa0d40b3 100644 --- a/crates/shared-sidecar/src/lib.rs +++ b/crates/shared-sidecar/src/lib.rs @@ -15,10 +15,11 @@ //! relies on the user profile's ACL, which already restricts it to the //! owner) and removes it on clean shutdown with [`remove_if_mine`]. //! 2. A reader ([`resolve`]) attaches only when the file is live: the pid -//! is alive, its process image is a `promptforge-gateway` binary (a -//! reused pid cannot impersonate the gateway), `GET /health` answers -//! 200, and the file's bearer key is accepted on a key-gated route. -//! Anything else is stale and the file is deleted. +//! is alive, one OS process boot with a `promptforge-gateway` image +//! brackets a same-socket health and bearer proof, and the file carries +//! a boot identity. Anything else is stale and the file is deleted. +//! [`ValidatedConnection`] carries that point-in-time proof without +//! exposing a forgeable constructor. //! 3. Launch races take [`launch_or_attach`]: the `gateway.json.lock` //! advisory lock elects one launcher; losers attach to the winner. //! 4. A reader asks the gateway to exit with [`request_shutdown`], which @@ -37,6 +38,7 @@ mod paths; mod shutdown; mod stale; mod sys; +mod validated; pub use crate::error::SidecarError; pub use crate::file::{ConnectionFile, remove_if_mine}; @@ -50,4 +52,5 @@ pub use crate::shutdown::{ShutdownError, request_shutdown}; #[cfg(feature = "test-fixtures")] #[doc(hidden)] pub use crate::stale::resolve_for_test; -pub use crate::stale::{Resolution, StaleReason, resolve}; +pub use crate::stale::{Resolution, StaleReason, is_running, resolve}; +pub use crate::validated::ValidatedConnection; diff --git a/crates/shared-sidecar/src/lock.rs b/crates/shared-sidecar/src/lock.rs index f724a13d..120847ff 100644 --- a/crates/shared-sidecar/src/lock.rs +++ b/crates/shared-sidecar/src/lock.rs @@ -144,9 +144,18 @@ mod tests { let port = listener.local_addr().expect("fixture address").port(); std::thread::spawn(move || { while let Ok((mut stream, _)) = listener.accept() { - let mut buffer = [0u8; 1024]; - let _ = stream.read(&mut buffer); - let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"); + for _ in 0..2 { + let mut buffer = [0u8; 1024]; + if stream.read(&mut buffer).is_err() { + break; + } + if stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}") + .is_err() + { + break; + } + } } }); port diff --git a/crates/shared-sidecar/src/shutdown.rs b/crates/shared-sidecar/src/shutdown.rs index 5802d1da..5fccc101 100644 --- a/crates/shared-sidecar/src/shutdown.rs +++ b/crates/shared-sidecar/src/shutdown.rs @@ -63,16 +63,15 @@ impl From for ShutdownError { pub fn request_shutdown(file: &ConnectionFile) -> Result<(), ShutdownError> { let address = format!("127.0.0.1:{}", file.port); let head = health::request_head(&address, "POST", SHUTDOWN_PATH, Some(&file.api_key))?; - let accepted = head + let status = head .split_whitespace() .nth(1) - .and_then(|code| code.parse::().ok()) - .is_some_and(|code| (200..300).contains(&code)); - if accepted { + .and_then(|code| code.parse::().ok()); + if status.is_some_and(|code| (200..300).contains(&code)) { return Ok(()); } Err(ShutdownError::Rejected { - status_line: head.lines().next().unwrap_or("").to_owned(), + status_line: status.map_or_else(|| "".to_owned(), |code| code.to_string()), }) } @@ -148,6 +147,22 @@ mod tests { ); } + #[test] + fn a_reflected_bearer_is_absent_from_shutdown_errors() { + let secret = "capability-secret"; + let response = format!("HTTP/1.1 401 rejected-{secret}\r\nContent-Length: 0\r\n\r\n"); + let response: &'static [u8] = Box::leak(response.into_bytes().into_boxed_slice()); + let (port, _received) = fixture_gateway(response); + let mut connection = file(port); + connection.api_key = secret.to_owned(); + + let error = request_shutdown(&connection).expect_err("a 401 is a refusal"); + assert!( + !format!("{error:?} {error}").contains(secret), + "bearer values never enter error diagnostics" + ); + } + #[test] fn a_dead_gateway_is_an_io_error() { // Port 1 is never listening, so the connect fails fast. diff --git a/crates/shared-sidecar/src/stale.rs b/crates/shared-sidecar/src/stale.rs index ead72dfe..c49b298e 100644 --- a/crates/shared-sidecar/src/stale.rs +++ b/crates/shared-sidecar/src/stale.rs @@ -1,42 +1,21 @@ //! Stale detection: decide whether a connection file names a live //! gateway, and remove it when it does not. //! -//! A file is live when the pid is alive, the pid's process image is a -//! `promptforge-gateway` binary (a reused pid cannot impersonate the -//! gateway), `GET /health` answers 200, and the file's bearer key is -//! accepted on a key-gated route. Anything else is stale - the Jupyter +//! A file is live when one OS process boot with a `promptforge-gateway` +//! image is unchanged across a same-socket health and bearer proof, and +//! the file carries a boot identity. Anything else is stale - the Jupyter //! phantom-server bug class - and the file is deleted so the next reader //! relaunches instead of retrying a corpse. -use std::ffi::OsStr; use std::fs; use std::io; use std::path::Path; -use std::time::Duration; use crate::ConnectionFile; use crate::error::SidecarError; -use crate::health::{self, KeyProbe}; use crate::paths::connection_file_path; -use crate::sys::process_image_path; - -/// The image file name a live gateway process must have. -#[cfg(windows)] -pub(crate) const GATEWAY_IMAGE_NAME: &str = "promptforge-gateway.exe"; -/// The image file name a live gateway process must have. -#[cfg(not(windows))] -pub(crate) const GATEWAY_IMAGE_NAME: &str = "promptforge-gateway"; - -/// The bearer-gated route used to prove the presented key is accepted. -/// `GET /v1/models` is key-gated in every gateway build. -const KEY_PROBE_PATH: &str = "/v1/models"; - -/// Budget the health probe gets before a file is condemned: the writer -/// lands the file before its serve loop starts accepting, and a busy -/// runtime can starve one probe, so a single failed attempt must never -/// read as stale - a false stale deletes a live gateway's file and a -/// reader relaunches a duplicate. -const LIVENESS_BUDGET: Duration = Duration::from_secs(2); +pub(crate) use crate::validated::GATEWAY_IMAGE_NAME; +use crate::validated::ValidatedConnection; /// What [`resolve`] found in the run directory. #[derive(Debug, Clone, PartialEq, Eq)] @@ -51,19 +30,30 @@ pub enum Resolution { } /// Why a connection file was judged stale. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] #[non_exhaustive] pub enum StaleReason { /// The file was not valid JSON or failed validation. + #[error("the connection file is invalid")] Invalid, /// The pid is dead. + #[error("the recorded gateway process is dead")] ProcessDead, /// The pid is alive but its image is not a `promptforge-gateway` /// binary (a reused pid). + #[error("the recorded pid belongs to another process image")] ImageMismatch, + /// The connection file does not carry a usable boot identity. + #[error("the connection file has no usable boot identity")] + BootIdentityInvalid, + /// The pid changed process boot while validation was in progress. + #[error("the recorded process identity changed during validation")] + ProcessChanged, /// The health endpoint did not answer 200. + #[error("the recorded gateway does not answer its health probe")] HealthFailed, /// The bearer key was rejected. + #[error("the connection file bearer was rejected")] KeyRejected, } @@ -100,43 +90,40 @@ pub(crate) fn resolve_named(run_dir: &Path, image_name: &str) -> Result return Err(error), }; - match liveness_failure(&file, image_name) { - None => Ok(Resolution::Attach(file)), - Some(reason) => { + match ValidatedConnection::validate_named(file, image_name) { + Ok(validated) => Ok(Resolution::Attach(validated.into_connection_file())), + Err(reason) => { remove_stale(run_dir)?; Ok(Resolution::Stale(reason)) } } } +/// Whether the connection file in `run_dir` names a live gateway right +/// now, with no cleanup: the read-only check a diagnostics report runs. +/// Stale-file deletion is the prospective owner's privilege, so a stale +/// file reads as not-running and stays on disk for the next launch to +/// clean. +#[must_use] +pub fn is_running(run_dir: &Path) -> bool { + is_running_named(run_dir, GATEWAY_IMAGE_NAME) +} + +/// [`is_running`] against a caller-named process image, so a test binary - +/// never named `promptforge-gateway` - can run the full liveness gauntlet. +pub(crate) fn is_running_named(run_dir: &Path, image_name: &str) -> bool { + match ConnectionFile::read(run_dir) { + Ok(Some(file)) => is_live(&file, image_name), + // A missing, unreadable, or invalid file reads as not-running. + Ok(None) | Err(_) => false, + } +} + /// Whether the file's gateway is live right now, with no cleanup: the /// check a launch-race loser runs, since deleting is the lock holder's /// privilege. pub(crate) fn is_live(file: &ConnectionFile, image_name: &str) -> bool { - liveness_failure(file, image_name).is_none() -} - -/// The first liveness check the file fails, or `None` when it is fully -/// live. -fn liveness_failure(file: &ConnectionFile, image_name: &str) -> Option { - let Some(image) = process_image_path(file.pid) else { - return Some(StaleReason::ProcessDead); - }; - if !image_name_matches(&image, image_name) { - return Some(StaleReason::ImageMismatch); - } - let port = file.port; - let address = format!("127.0.0.1:{port}"); - if health::wait_for_health(&format!("http://{address}"), LIVENESS_BUDGET).is_err() { - return Some(StaleReason::HealthFailed); - } - match health::probe_bearer(&address, KEY_PROBE_PATH, &file.api_key) { - KeyProbe::Accepted => None, - KeyProbe::Rejected => Some(StaleReason::KeyRejected), - // The health probe answered moments ago; a now-silent server is a - // health failure, not a key rejection. - KeyProbe::Unreachable => Some(StaleReason::HealthFailed), - } + ValidatedConnection::validate_named(file.clone(), image_name).is_ok() } /// Deletes the stale connection file, tolerating a concurrent deletion. @@ -152,28 +139,6 @@ fn remove_stale(run_dir: &Path) -> Result<(), SidecarError> { } } -/// Whether the image path's file name matches the expected gateway image -/// name. -fn image_name_matches(image: &Path, expected: &str) -> bool { - let Some(name) = image.file_name() else { - return false; - }; - image_file_name_matches(name, expected) -} - -/// Windows filesystems are case-insensitive; match the image name the -/// same way. -#[cfg(windows)] -fn image_file_name_matches(name: &OsStr, expected: &str) -> bool { - name.to_string_lossy().eq_ignore_ascii_case(expected) -} - -/// Unix filesystems are case-sensitive; match the image name exactly. -#[cfg(not(windows))] -fn image_file_name_matches(name: &OsStr, expected: &str) -> bool { - name == OsStr::new(expected) -} - #[cfg(test)] mod tests { use super::*; @@ -228,19 +193,23 @@ mod tests { let port = listener.local_addr().expect("fixture address").port(); std::thread::spawn(move || { while let Ok((mut stream, _)) = listener.accept() { - let mut buffer = [0u8; 1024]; - let Ok(read) = stream.read(&mut buffer) else { - continue; - }; - let request = String::from_utf8_lossy(&buffer[..read]); - let response = if request.starts_with("GET /health ") - || request.contains(&format!("Authorization: Bearer {expected_key}\r\n")) - { - &b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"[..] - } else { - &b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"[..] - }; - let _ = stream.write_all(response); + for _ in 0..2 { + let mut buffer = [0u8; 1024]; + let Ok(read) = stream.read(&mut buffer) else { + break; + }; + let request = String::from_utf8_lossy(&buffer[..read]); + let accepted = request.starts_with("GET /health ") + || request.contains(&format!("Authorization: Bearer {expected_key}\r\n")); + let response = if accepted { + &b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"[..] + } else { + &b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"[..] + }; + if stream.write_all(response).is_err() { + break; + } + } } }); port @@ -308,9 +277,18 @@ mod tests { drop(stream); continue; } - let mut buffer = [0u8; 1024]; - let _ = stream.read(&mut buffer); - let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"); + for _ in 0..2 { + let mut buffer = [0u8; 1024]; + if stream.read(&mut buffer).is_err() { + break; + } + if stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}") + .is_err() + { + break; + } + } } }); let file = live_file(port, "key"); @@ -358,4 +336,59 @@ mod tests { "a live file is left in place" ); } + + #[test] + fn is_running_reports_a_live_gateway_without_touching_the_file() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let port = fixture_gateway("right"); + live_file(port, "right") + .write_to(dir.path()) + .expect("write"); + + assert!( + is_running_named(dir.path(), &own_image_name()), + "a fully live file reads as running" + ); + assert!( + connection_file_path(dir.path()).exists(), + "the read-only check never deletes" + ); + } + + #[test] + fn is_running_leaves_a_stale_file_in_place() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let file = ConnectionFile { + pid: dead_pid(), + ..live_file(1, "key") + }; + file.write_to(dir.path()).expect("write"); + + assert!( + !is_running_named(dir.path(), &own_image_name()), + "a dead pid reads as not-running" + ); + assert!( + connection_file_path(dir.path()).exists(), + "stale-file deletion is the prospective owner's privilege" + ); + } + + #[test] + fn is_running_reads_absent_and_corrupt_files_as_not_running() { + let dir = tempfile::TempDir::new().expect("tempdir"); + assert!( + !is_running_named(dir.path(), &own_image_name()), + "no connection file reads as not-running" + ); + fs::write(connection_file_path(dir.path()), b"not json").expect("write fixture"); + assert!( + !is_running_named(dir.path(), &own_image_name()), + "a corrupt file reads as not-running and is left alone" + ); + assert!( + connection_file_path(dir.path()).exists(), + "the corrupt file was not deleted" + ); + } } diff --git a/crates/shared-sidecar/src/sys.rs b/crates/shared-sidecar/src/sys.rs index 5cfef82c..66f87beb 100644 --- a/crates/shared-sidecar/src/sys.rs +++ b/crates/shared-sidecar/src/sys.rs @@ -1,34 +1,58 @@ -//! Process image lookup for stale detection: one shim per platform, each -//! answering "what binary does this pid run", so a reused pid cannot -//! impersonate the gateway that wrote a connection file. A live answer -//! doubles as the liveness check: a dead pid has no image to query. +//! Process identity lookup for stale detection: one shim per platform, +//! each answering "what binary does this pid run, and which process boot +//! owns the pid", so pid reuse cannot join separate validation observations. + +use std::path::PathBuf; + +/// An OS-observed process boot, stable for one lifetime and different +/// when the pid is reused. +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct ProcessIdentity { + /// The process executable observed for this boot. + pub(crate) image: PathBuf, + /// Platform start marker: FILETIME, proc start ticks, or timeval. + started: u128, +} + +impl ProcessIdentity { + /// Assembles one platform observation. + pub(crate) const fn new(image: PathBuf, started: u128) -> Self { + Self { image, started } + } + + /// Assembles a deterministic identity for validation regressions. + #[cfg(test)] + pub(crate) const fn for_test(image: PathBuf, started: u128) -> Self { + Self::new(image, started) + } +} #[cfg(target_os = "linux")] mod linux; #[cfg(target_os = "macos")] #[expect( unsafe_code, - reason = "proc_pidpath is a raw C API with no safe wrapper" + reason = "proc_pidpath and proc_pidinfo are raw C APIs with no safe wrappers" )] mod macos; #[cfg(windows)] #[expect( unsafe_code, - reason = "OpenProcess and QueryFullProcessImageNameW are raw Win32 with no safe wrapper" + reason = "process identity uses raw Win32 handle and query APIs" )] mod windows; #[cfg(target_os = "linux")] -pub(crate) use linux::process_image_path; +pub(crate) use linux::process_identity; #[cfg(target_os = "macos")] -pub(crate) use macos::process_image_path; +pub(crate) use macos::process_identity; #[cfg(windows)] -pub(crate) use windows::process_image_path; +pub(crate) use windows::process_identity; -/// Every other platform fails closed: no image answer means the connection -/// file is always treated as stale, so a reader relaunches rather than +/// Every other platform fails closed: no process identity means the +/// connection file is always stale, so a reader relaunches rather than /// attaching to an unverified process. #[cfg(not(any(windows, target_os = "linux", target_os = "macos")))] -pub(crate) fn process_image_path(_pid: u32) -> Option { +pub(crate) fn process_identity(_pid: u32) -> Option { None } diff --git a/crates/shared-sidecar/src/sys/linux.rs b/crates/shared-sidecar/src/sys/linux.rs index 19ff511f..7b06f1c4 100644 --- a/crates/shared-sidecar/src/sys/linux.rs +++ b/crates/shared-sidecar/src/sys/linux.rs @@ -1,12 +1,22 @@ -//! Linux process image lookup: the `/proc//exe` symlink answers both -//! liveness and identity - a dead process (or a zombie) has no `exe` link -//! to read. +//! Linux process identity lookup: `/proc//exe` supplies the image and +//! field 22 of `/proc//stat` supplies the kernel start tick. -use std::path::PathBuf; +use super::ProcessIdentity; -/// The kernel's path for the process's executable, or `None` when the -/// process is gone or the link cannot be read (a dead pid, a zombie, or -/// an unreadable `/proc`). -pub(crate) fn process_image_path(pid: u32) -> Option { - std::fs::read_link(format!("/proc/{pid}/exe")).ok() +/// The image and start tick for process `pid`, or `None` when the process +/// disappears, changes identity during observation, or `/proc` is unreadable. +pub(crate) fn process_identity(pid: u32) -> Option { + let first_start = process_start(pid)?; + let image = std::fs::read_link(format!("/proc/{pid}/exe")).ok()?; + let second_start = process_start(pid)?; + (first_start == second_start).then(|| ProcessIdentity::new(image, u128::from(first_start))) +} + +/// Reads Linux `/proc//stat` field 22. The command field is enclosed +/// in parentheses and may itself contain spaces or closing parentheses, +/// so fields are counted only after its final delimiter. +fn process_start(pid: u32) -> Option { + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + let after_command = stat.get(stat.rfind(')')? + 1..)?; + after_command.split_whitespace().nth(19)?.parse().ok() } diff --git a/crates/shared-sidecar/src/sys/macos.rs b/crates/shared-sidecar/src/sys/macos.rs index aa4baa23..598c294d 100644 --- a/crates/shared-sidecar/src/sys/macos.rs +++ b/crates/shared-sidecar/src/sys/macos.rs @@ -1,18 +1,58 @@ -//! macOS process image lookup via `proc_pidpath` (libproc, part of -//! libSystem): one call answers both liveness and identity - a dead pid -//! has no image to report. +//! macOS process identity lookup via libproc: `proc_pidpath` supplies the +//! image and `PROC_PIDTBSDINFO` supplies the process start timeval. use std::ffi::OsString; +use std::mem::{MaybeUninit, size_of}; use std::os::unix::ffi::OsStringExt as _; use std::path::PathBuf; +use super::ProcessIdentity; + /// Buffer size for `proc_pidpath`: `PROC_PIDPATHINFO_MAXSIZE` from /// `libproc.h` (4 * MAXPATHLEN). const PROC_PIDPATHINFO_MAXSIZE: u32 = 4096; +/// `PROC_PIDTBSDINFO` from `libproc.h`. +const PROC_PIDTBSDINFO: i32 = 3; + +/// The stable prefix and start fields of Darwin's `proc_bsdinfo`. +#[repr(C)] +struct ProcBsdInfo { + pbi_flags: u32, + pbi_status: u32, + pbi_xstatus: u32, + pbi_pid: u32, + pbi_ppid: u32, + pbi_uid: u32, + pbi_gid: u32, + pbi_ruid: u32, + pbi_rgid: u32, + pbi_svuid: u32, + pbi_svgid: u32, + rfu_1: u32, + pbi_comm: [libc::c_char; 16], + pbi_name: [libc::c_char; 32], + pbi_nfiles: u32, + pbi_pgid: u32, + pbi_pjobc: u32, + e_tdev: u32, + e_tpgid: u32, + pbi_nice: i32, + pbi_start_tvsec: u64, + pbi_start_tvusec: u64, +} + +/// The image and start timeval for process `pid`, or `None` when the +/// process disappears, changes identity during observation, or refuses +/// either query. +pub(crate) fn process_identity(pid: u32) -> Option { + let first_start = process_start(pid)?; + let image = process_image_path(pid)?; + let second_start = process_start(pid)?; + (first_start == second_start).then(|| ProcessIdentity::new(image, first_start)) +} -/// The kernel's path for the process's executable, or `None` when the -/// process is gone or refuses the query. -pub(crate) fn process_image_path(pid: u32) -> Option { +/// Reads the kernel's path for one live process. +fn process_image_path(pid: u32) -> Option { let pid = i32::try_from(pid).ok()?; let mut buffer = vec![0u8; PROC_PIDPATHINFO_MAXSIZE as usize]; // SAFETY: `buffer` is a live allocation of exactly @@ -28,3 +68,29 @@ pub(crate) fn process_image_path(pid: u32) -> Option { buffer.truncate(written); Some(PathBuf::from(OsString::from_vec(buffer))) } + +/// Reads the process start timeval through `PROC_PIDTBSDINFO`. +fn process_start(pid: u32) -> Option { + let pid = i32::try_from(pid).ok()?; + let buffer_size = i32::try_from(size_of::()).ok()?; + let mut info = MaybeUninit::::uninit(); + // SAFETY: `info` points to writable storage of exactly `buffer_size` + // bytes. A full-size success initializes the complete structure before + // `assume_init`; every other result returns without reading it. + let written = unsafe { + libc::proc_pidinfo( + pid, + PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr().cast(), + buffer_size, + ) + }; + if written != buffer_size { + return None; + } + // SAFETY: the full-size `proc_pidinfo` success above initialized every + // byte of the `ProcBsdInfo` output structure. + let info = unsafe { info.assume_init() }; + Some(u128::from(info.pbi_start_tvsec) << 64 | u128::from(info.pbi_start_tvusec)) +} diff --git a/crates/shared-sidecar/src/sys/windows.rs b/crates/shared-sidecar/src/sys/windows.rs index c268507a..58731f16 100644 --- a/crates/shared-sidecar/src/sys/windows.rs +++ b/crates/shared-sidecar/src/sys/windows.rs @@ -1,19 +1,20 @@ -//! Windows process image lookup: `OpenProcess` + -//! `QueryFullProcessImageNameW` answer liveness and identity together - a -//! dead pid opens no handle once its last handle closes. +//! Windows process identity lookup: one process handle supplies the image +//! and creation FILETIME, so pid reuse cannot join two observations. use std::ffi::OsString; use std::os::windows::ffi::OsStringExt as _; use std::path::PathBuf; -use windows_sys::Win32::Foundation::{CloseHandle, HANDLE}; +use windows_sys::Win32::Foundation::{CloseHandle, FILETIME, HANDLE}; use windows_sys::Win32::System::Threading::{ - OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, + GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, }; -/// The full image path of process `pid`, or `None` when the process is -/// dead or refuses a limited query. -pub(crate) fn process_image_path(pid: u32) -> Option { +use super::ProcessIdentity; + +/// The image and creation time of process `pid`, or `None` when the +/// process is dead or refuses a limited query. +pub(crate) fn process_identity(pid: u32) -> Option { // SAFETY: `OpenProcess` takes a valid access mask and pid; the // returned handle is either null (checked) or a live process handle // that `CloseHandle` below releases exactly once. @@ -21,13 +22,41 @@ pub(crate) fn process_image_path(pid: u32) -> Option { if handle.is_null() { return None; } - let image = query_image_path(handle); + let identity = query_identity(handle); // SAFETY: `handle` is the live process handle returned by the // `OpenProcess` above, closed exactly once here. unsafe { CloseHandle(handle); } - image + identity +} + +/// Reads one coherent image and creation time from an open process handle. +fn query_identity(handle: HANDLE) -> Option { + let image = query_image_path(handle)?; + let mut creation = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let mut exit = creation; + let mut kernel = creation; + let mut user = creation; + // SAFETY: all pointers name initialized writable FILETIME values, and + // `handle` remains open for the complete query. + let ok = unsafe { + GetProcessTimes( + handle, + &raw mut creation, + &raw mut exit, + &raw mut kernel, + &raw mut user, + ) + }; + if ok == 0 { + return None; + } + let started = u128::from(creation.dwHighDateTime) << 32 | u128::from(creation.dwLowDateTime); + Some(ProcessIdentity::new(image, started)) } /// Reads the image path from an open process handle. diff --git a/crates/shared-sidecar/src/validated.rs b/crates/shared-sidecar/src/validated.rs new file mode 100644 index 00000000..62ea4b05 --- /dev/null +++ b/crates/shared-sidecar/src/validated.rs @@ -0,0 +1,514 @@ +//! A live Gateway connection whose process and authority have been +//! validated. + +use std::ffi::OsStr; +use std::fmt; +use std::path::Path; +use std::time::Duration; + +use crate::ConnectionFile; +use crate::health::{self, ConnectionProbe}; +use crate::stale::StaleReason; +use crate::sys::{ProcessIdentity, process_identity}; + +/// The image file name a live Gateway process must have. +#[cfg(windows)] +pub(crate) const GATEWAY_IMAGE_NAME: &str = "promptforge-gateway.exe"; +/// The image file name a live Gateway process must have. +#[cfg(not(windows))] +pub(crate) const GATEWAY_IMAGE_NAME: &str = "promptforge-gateway"; + +/// The bearer-gated route used to prove the presented key is accepted. +const KEY_PROBE_PATH: &str = "/v1/models"; + +/// Budget for proving health without condemning one transient failure. +const LIVENESS_BUDGET: Duration = Duration::from_secs(2); + +/// A Gateway connection proven live and authorized at construction time. +/// +/// Safe code outside this crate cannot construct the capability directly. +/// [`ValidatedConnection::validate`] is the only production entry point, +/// and it succeeds only after checking the process image, boot identity, +/// health endpoint, and bearer acceptance. +/// +/// Validation observes the OS process boot immediately before and after +/// one TCP connection carries both network checks. This closes the +/// health-to-bearer replacement gap and rejects pid reuse during that +/// interval. The capability is a point-in-time proof and makes no claim +/// that the process remains live after validation returns. +/// +/// The bearer is deliberately absent from [`Debug`](fmt::Debug) output. +/// +/// ```compile_fail +/// use shared_sidecar::ValidatedConnection; +/// +/// let _raw = ValidatedConnection { +/// connection: panic!("external code cannot fill the private field"), +/// }; +/// ``` +/// +/// Even with the public test-fixture feature enabled, external code cannot +/// choose the process image used to mint a production capability: +/// +/// ```compile_fail +/// use shared_sidecar::{ConnectionFile, ValidatedConnection}; +/// +/// let raw = ConnectionFile { +/// port: 8081, +/// api_key: "forged".into(), +/// pid: std::process::id(), +/// epoch: 1, +/// version: "test".into(), +/// started_at: "2026-09-07T00:00:00Z".into(), +/// }; +/// let _ = ValidatedConnection::validate_for_test(raw, "my-test-binary"); +/// ``` +/// +/// The crate-private named validator is equally unavailable: +/// +/// ```compile_fail +/// use shared_sidecar::{ConnectionFile, ValidatedConnection}; +/// +/// let raw = ConnectionFile { +/// port: 8081, +/// api_key: "forged".into(), +/// pid: std::process::id(), +/// epoch: 1, +/// version: "test".into(), +/// started_at: "2026-09-07T00:00:00Z".into(), +/// }; +/// let _ = ValidatedConnection::validate_named(raw, "my-test-binary"); +/// ``` +#[derive(Clone, PartialEq, Eq)] +pub struct ValidatedConnection { + connection: ConnectionFile, + process_identity: ProcessIdentity, +} + +impl ValidatedConnection { + /// Validates a raw connection file against the production Gateway image. + /// + /// # Errors + /// Returns the first [`StaleReason`] that prevents the raw connection + /// from proving a live, authorized Gateway boot. + pub fn validate(connection: ConnectionFile) -> Result { + Self::validate_named(connection, GATEWAY_IMAGE_NAME) + } + + /// Creates a fixture capability tied to the calling test process. + /// + /// Structural and process-identity checks remain real. Network proof is + /// skipped because cross-crate mock gateways often expose only the route + /// under test. Test builds only, behind the `test-fixtures` feature. + #[cfg(feature = "test-fixtures")] + #[doc(hidden)] + pub fn validate_for_test(connection: ConnectionFile) -> Result { + let Some(image_name) = std::env::current_exe() + .ok() + .and_then(|path| path.file_name().map(OsStr::to_owned)) + else { + return Err(StaleReason::ImageMismatch); + }; + validate_with( + connection, + &image_name.to_string_lossy(), + process_identity, + |_, _, _| ConnectionProbe::Accepted, + ) + } + + pub(crate) fn validate_named( + connection: ConnectionFile, + image_name: &str, + ) -> Result { + validate_with( + connection, + image_name, + process_identity, + |address, bearer, budget| { + health::probe_connection(address, KEY_PROBE_PATH, bearer, budget) + }, + ) + } + + /// The validated Gateway's loopback port. + #[must_use] + pub const fn port(&self) -> u16 { + self.connection.port + } + + /// The validated Gateway process identifier. + #[must_use] + pub const fn pid(&self) -> u32 { + self.connection.pid + } + + /// The validated Gateway boot epoch. + #[must_use] + pub const fn epoch(&self) -> u64 { + self.connection.epoch + } + + /// The validated Gateway version. + #[must_use] + pub fn version(&self) -> &str { + &self.connection.version + } + + /// The validated Gateway boot timestamp. + #[must_use] + pub fn started_at(&self) -> &str { + &self.connection.started_at + } + + /// Whether both capabilities name the same validated Gateway boot. + #[must_use] + pub fn same_boot(&self, other: &Self) -> bool { + self.process_identity == other.process_identity + && self.pid() == other.pid() + && self.epoch() == other.epoch() + && self.started_at() == other.started_at() + } + + /// The validated bearer required to build an authorized consumer. + /// + /// Callers must keep this value out of diagnostics. The capability's + /// own [`Debug`](fmt::Debug) implementation always redacts it. + #[doc(hidden)] + #[must_use] + pub fn api_key(&self) -> &str { + &self.connection.api_key + } + + pub(crate) fn into_connection_file(self) -> ConnectionFile { + self.connection + } +} + +impl fmt::Debug for ValidatedConnection { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ValidatedConnection") + .field("port", &self.port()) + .field("pid", &self.pid()) + .field("epoch", &self.epoch()) + .field("version", &"[REDACTED]") + .field("started_at", &"[REDACTED]") + .field("api_key", &"[REDACTED]") + .finish() + } +} + +/// Performs one validation while allowing deterministic observation and +/// network seams in unit tests. Production passes only the platform process +/// observer and the private shared probe. +fn validate_with( + connection: ConnectionFile, + image_name: &str, + mut observe_process: impl FnMut(u32) -> Option, + prove_connection: impl FnOnce(&str, &str, Duration) -> ConnectionProbe, +) -> Result { + if connection.validation_error().is_some() { + return Err(StaleReason::Invalid); + } + let Some(before) = observe_process(connection.pid) else { + return Err(StaleReason::ProcessDead); + }; + if !image_name_matches(&before.image, image_name) { + return Err(StaleReason::ImageMismatch); + } + if !connection.has_boot_identity() { + return Err(StaleReason::BootIdentityInvalid); + } + let address = format!("127.0.0.1:{}", connection.port); + match prove_connection(&address, &connection.api_key, LIVENESS_BUDGET) { + ConnectionProbe::HealthFailed => return Err(StaleReason::HealthFailed), + ConnectionProbe::KeyRejected => return Err(StaleReason::KeyRejected), + ConnectionProbe::Accepted => {} + } + let Some(after) = observe_process(connection.pid) else { + return Err(StaleReason::ProcessChanged); + }; + if before != after { + return Err(StaleReason::ProcessChanged); + } + Ok(ValidatedConnection { + connection, + process_identity: before, + }) +} + +fn image_name_matches(image: &Path, expected: &str) -> bool { + let Some(name) = image.file_name() else { + return false; + }; + image_file_name_matches(name, expected) +} + +#[cfg(windows)] +fn image_file_name_matches(name: &OsStr, expected: &str) -> bool { + name.to_string_lossy().eq_ignore_ascii_case(expected) +} + +#[cfg(not(windows))] +fn image_file_name_matches(name: &OsStr, expected: &str) -> bool { + name == OsStr::new(expected) +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::io::{Read, Write as _}; + use std::net::TcpListener; + + use crate::ConnectionFile; + + fn own_image_name() -> String { + std::env::current_exe() + .expect("current exe") + .file_name() + .expect("the exe has a file name") + .to_string_lossy() + .into_owned() + } + + fn connection(port: u16, api_key: &str) -> ConnectionFile { + ConnectionFile { + port, + api_key: api_key.to_owned(), + pid: std::process::id(), + epoch: 1_778_000_000, + version: "0.2.0".to_owned(), + started_at: "2026-05-05T12:00:00Z".to_owned(), + } + } + + fn dead_pid() -> u32 { + let mut child = std::process::Command::new(std::env::current_exe().expect("current exe")) + .arg("--list") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn a short-lived child"); + let pid = child.id(); + child.wait().expect("the child exits"); + pid + } + + fn fixture_gateway(expected_key: &'static str) -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture"); + let port = listener.local_addr().expect("fixture address").port(); + std::thread::spawn(move || { + while let Ok((mut stream, _)) = listener.accept() { + for _ in 0..2 { + let mut buffer = [0_u8; 1024]; + let Ok(read) = stream.read(&mut buffer) else { + break; + }; + let request = String::from_utf8_lossy(&buffer[..read]); + let accepted = request.starts_with("GET /health ") + || request.contains(&format!("Authorization: Bearer {expected_key}\r\n")); + let response = if accepted { + &b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"[..] + } else { + &b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"[..] + }; + if stream.write_all(response).is_err() { + break; + } + } + } + }); + port + } + + #[test] + fn a_wrong_process_image_cannot_create_a_capability() { + let file = connection(1, "key"); + + assert_eq!( + ValidatedConnection::validate_named(file, "not-the-test-binary"), + Err(StaleReason::ImageMismatch) + ); + } + + #[test] + fn a_dead_process_cannot_create_a_capability() { + let file = ConnectionFile { + pid: dead_pid(), + ..connection(1, "key") + }; + + assert_eq!( + ValidatedConnection::validate_named(file, &own_image_name()), + Err(StaleReason::ProcessDead) + ); + } + + #[test] + fn a_stale_boot_identity_cannot_create_a_capability() { + let missing_epoch = ConnectionFile { + epoch: 0, + ..connection(1, "key") + }; + assert_eq!( + ValidatedConnection::validate_named(missing_epoch, &own_image_name()), + Err(StaleReason::BootIdentityInvalid) + ); + + let missing_start = ConnectionFile { + started_at: String::new(), + ..connection(1, "key") + }; + assert_eq!( + ValidatedConnection::validate_named(missing_start, &own_image_name()), + Err(StaleReason::BootIdentityInvalid) + ); + } + + #[test] + fn an_invalid_raw_connection_cannot_create_a_capability() { + let file = ConnectionFile { + port: 0, + ..connection(1, "key") + }; + + assert_eq!( + ValidatedConnection::validate_named(file, &own_image_name()), + Err(StaleReason::Invalid) + ); + } + + #[test] + fn failed_health_cannot_create_a_capability() { + let file = connection(1, "key"); + + assert_eq!( + ValidatedConnection::validate_named(file, &own_image_name()), + Err(StaleReason::HealthFailed) + ); + } + + #[test] + fn a_rejected_bearer_cannot_create_a_capability() { + let port = fixture_gateway("accepted"); + let file = connection(port, "rejected"); + + assert_eq!( + ValidatedConnection::validate_named(file, &own_image_name()), + Err(StaleReason::KeyRejected) + ); + } + + #[test] + fn a_new_boot_with_the_same_port_and_key_creates_a_distinct_capability() { + let port = fixture_gateway("stable-key"); + let original = + ValidatedConnection::validate_named(connection(port, "stable-key"), &own_image_name()) + .expect("the original connection validates"); + let replacement_file = ConnectionFile { + epoch: original.epoch() + 1, + started_at: "2026-05-05T12:00:01Z".to_owned(), + ..connection(port, "stable-key") + }; + let replacement = ValidatedConnection::validate_named(replacement_file, &own_image_name()) + .expect("the replacement connection validates"); + + assert_eq!(replacement.port(), original.port()); + assert_eq!(replacement.api_key(), original.api_key()); + assert!(!replacement.same_boot(&original)); + } + + #[test] + fn debug_output_never_contains_the_bearer() { + let secret = "capability-secret"; + let port = fixture_gateway(secret); + let mut raw = connection(port, secret); + raw.version = format!("version-{secret}\r\n"); + raw.started_at = format!("started-{secret}\t"); + let validated = ValidatedConnection::validate_named(raw, &own_image_name()) + .expect("the connection validates"); + + let debug = format!("{validated:?}"); + assert!(!debug.contains(secret), "debug output redacts the bearer"); + assert!( + !debug.contains(['\r', '\n', '\t']), + "untrusted metadata cannot inject debug output" + ); + assert!(debug.contains(&port.to_string()), "the endpoint is visible"); + } + + #[test] + fn a_process_boot_change_during_the_network_proof_is_rejected() { + let image = std::path::PathBuf::from(own_image_name()); + let before = ProcessIdentity::for_test(image.clone(), 41); + let after = ProcessIdentity::for_test(image, 42); + let mut observations = [Some(before), Some(after)].into_iter(); + + assert_eq!( + validate_with( + connection(8081, "key"), + &own_image_name(), + |_| observations.next().flatten(), + |_, _, _| ConnectionProbe::Accepted, + ), + Err(StaleReason::ProcessChanged), + "a reused pid cannot complete a mixed proof" + ); + } + + #[test] + fn forged_file_identity_cannot_alias_a_reused_process() { + let raw = connection(8081, "key"); + let image = std::path::PathBuf::from(own_image_name()); + let first_identity = ProcessIdentity::for_test(image.clone(), 41); + let second_identity = ProcessIdentity::for_test(image, 42); + let first = validate_with( + raw.clone(), + &own_image_name(), + |_| Some(first_identity.clone()), + |_, _, _| ConnectionProbe::Accepted, + ) + .expect("the first coherent proof validates"); + let second = validate_with( + raw, + &own_image_name(), + |_| Some(second_identity.clone()), + |_, _, _| ConnectionProbe::Accepted, + ) + .expect("the replacement's coherent proof validates"); + + assert!( + !first.same_boot(&second), + "identical attacker-controlled file fields cannot forge process identity" + ); + } + + #[test] + fn validation_errors_never_contain_the_bearer_or_metadata() { + let secret = "capability-secret"; + let mut raw = connection(8081, secret); + raw.version = format!("version-{secret}\r\n"); + raw.started_at = format!("started-{secret}\t"); + let image = std::path::PathBuf::from(own_image_name()); + let identity = ProcessIdentity::for_test(image, 41); + let error = validate_with( + raw, + &own_image_name(), + |_| Some(identity.clone()), + |_, _, _| ConnectionProbe::KeyRejected, + ) + .expect_err("the bearer is rejected"); + + let debug = format!("{error:?}"); + let display = format!("{error}"); + for rendered in [debug, display] { + assert!(!rendered.contains(secret), "errors redact the bearer"); + assert!( + !rendered.contains(['\r', '\n', '\t']), + "untrusted metadata cannot inject errors" + ); + } + } +} diff --git a/crates/workshop-server/AGENTS.md b/crates/workshop-server/AGENTS.md index e18cd240..c2153c51 100644 --- a/crates/workshop-server/AGENTS.md +++ b/crates/workshop-server/AGENTS.md @@ -2,13 +2,13 @@ This crate owns the workshop HTTP/WebSocket server: loopback listener, status bus, asset serving, session endpoints, and the host-embeddable spawn surface the desktop app attaches to. -- Two-zone error policy. Zone one (config load and server construction): return rich errors to the host; never panic for configuration, binding, asset, or initialization failures - the host decides how failure surfaces; binary entry points may convert a returned error to a failing exit status. Zone two (request and session handling): never panic, never `unwrap` anything a client sent; errors are values (error frames, 4xx/5xx, status-bus reports, logged degradation); a lock poisoned by a panicking peer recovers the value rather than wedging the process. Degrade-not-crash features (STT provisioning, gateway outages) are zone two by definition. +- Two-zone error policy. Zone one (config load and server construction): return rich errors to the host; never panic for configuration, binding, asset, or initialization failures - the host decides how failure surfaces; binary entry points may convert a returned error to a failing exit status. Zone two (request and session handling): never panic, never `unwrap` anything a client sent; errors are values (error frames, 4xx/5xx, status-bus reports, logged degradation); a lock poisoned by a panicking peer recovers the value rather than wedging the process. Gateway outages are zone two by definition. - Embedding hygiene deltas for this crate: never unconditionally init global tracing; keep no `OnceLock` singletons that ignore their arguments; the workshop listener binds loopback only - only the gateway's own listener may bind wider. Bind and init failures return through the spawn handshake (workspace `process::exit` / process-global rules still apply). -- The gateway owns STT through `gateway-stt`: artifact provisioning, engine construction and teardown, the `/stt` WebSocket, and OpenAI multipart transcription stay outside this crate. This crate supplies the Workshop listener, status bus, and cross-site guard that gateway-owned STT routes attach to through `spawn_with_routes`. It never depends on `gateway-transcribe` or holds whisper model state. +- The Realtime transcription relay authenticates upstream, validates browser origin, and never parses speech payloads or owns speech state. - One task owns each socket: a single `select!` loop reads and writes the same socket handle. No outbox channel, no writer task, no session registry for per-request relay work. Durable messages deliver via `Notify` plus a per-client cursor and coalesce; ephemeral messages go through a bounded broadcast and drop on lag. Malformed inbound frames are logged and skipped, or close the connection with a policy code - never a panic. Each endpoint owns its socket, task, channels, protocol policy, and cleanup. The session owns transport, not chat execution: chat runs through agent sessions, never on this socket. - Carve-out: agent sessions (`session_agents`) keep a session registry because agent sessions survive socket disconnect by design - sockets attach and detach, reconnect replays the persisted event log and re-announces unresolved waits. The no-session-registry rule stands for every other endpoint. - Every pushed message type is classified in the protocol module as durable or ephemeral; no message type ships unclassified. Durable state is recoverable from retained state or a cursor, and consumers tolerate duplicate delivery. Ephemeral snapshots may coalesce or drop under lag; the latest complete snapshot is resent on reconnect. -- Work held on behalf of a client - a gateway completion, a whisper job, an input wait - is wrapped in a guard that cancels on disconnect. A resource that still needs a manual cleanup call is a wrong factoring. +- Work held on behalf of a client - a gateway completion or an input wait - is wrapped in a guard that cancels on disconnect. A resource that still needs a manual cleanup call is a wrong factoring. - Gate the leaf, not the call site: a feature cfg's one function body; router composition and `main` stay feature-blind; features forward through Cargo.toml cascades. Never inline cfg-else pairs inside composition expressions. - Each feature module exports `fn routes(state) -> Router`. `app.rs` is composition plus `AppState` only. Narrow state per route group with plain `with_state`. A module name states its responsibility; when the name no longer covers what the module owns, rename or split it before adding another responsibility. Use `session.rs` beside `session/`; never introduce `session/mod.rs`. - The ceiling ratchet prevents regrowth, not responsibility drift: a server module may not grow past its recorded ceiling, a ceiling is never raised to add a new responsibility, and a split records every new module at its actual size while removing or lowering the old ceiling in the same commit. diff --git a/crates/workshop-server/Cargo.toml b/crates/workshop-server/Cargo.toml index 3a159016..53dcc852 100644 --- a/crates/workshop-server/Cargo.toml +++ b/crates/workshop-server/Cargo.toml @@ -15,6 +15,7 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true +arc-swap.workspace = true async-trait.workspace = true axum.workspace = true dunce.workspace = true @@ -31,6 +32,7 @@ reqwest.workspace = true rust-embed.workspace = true serde.workspace = true serde_json.workspace = true +shared-loopback.workspace = true shared-sidecar.workspace = true socket2.workspace = true thiserror.workspace = true diff --git a/crates/workshop-server/README.md b/crates/workshop-server/README.md index e28f0342..0296f0fd 100644 --- a/crates/workshop-server/README.md +++ b/crates/workshop-server/README.md @@ -2,7 +2,7 @@ [![License](https://img.shields.io/badge/license-BSL--1.0-blue.svg)](LICENSE) -The PromptForge Workshop HTTP server. It serves a local UI and API on loopback: agent sessions (chat runs through `.lua` agent programs over `promptforge-agent`), an OpenAI-shaped model catalog passthrough in front of a PromptForge gateway, workspace APIs, and a same-origin relay to the gateway-owned speech routes. The desktop shell (`workshop`) embeds it in-process; run standalone it is the browser-tab frame. +The PromptForge Workshop HTTP server. It serves a local UI and API on loopback: agent sessions (chat runs through `.lua` agent programs over `promptforge-agent`), an OpenAI-shaped model catalog passthrough in front of a PromptForge gateway, workspace APIs, and a same-origin payload-opaque relay to Gateway Realtime transcription. The desktop shell (`workshop`) embeds it in-process; run standalone it is the browser-tab frame. ## Quick start @@ -52,6 +52,7 @@ Every field of `workshop.toml`: | `GET /health` | Health probe; answers `{"status":"serving"}` | | `GET /` | The chat UI (also `/app.js`, `/app.css`, `/style.css`, `/pcm-worklet.js`, bundled by the crate's build script: read from disk in debug builds, embedded in the binary in release builds) | | `GET /v1/models` | Proxies the gateway's model catalog verbatim; while the gateway is known down, answers 502 `gateway_unreachable` without attempting it | +| `GET /v1/realtime` | Same-origin WebSocket relay to the gateway's fixed `/v1/realtime?intent=transcription` target; validates browser Origin, attaches gateway authentication upstream, rejects subprotocols, preserves text, binary, and close frames, and never parses speech payloads | | `GET /ws` | WebSocket upgrade, one persistent socket for the workshop's downstream JSON: unsolicited `{"type":"status","label","description","severity","activity","progress"}` observer updates, `{"type":"models","models":[...]}` catalog pushes, and `{"type":"workbench",...}` Model-menu snapshots out; `{"type":"select_model","model"}` and `{"type":"switch_profile","name"}` menu events in, refusals answered with `{"type":"error","message"}` frames | | `GET /agents/ws` | WebSocket upgrade for one agent session: the discovered agent list on connect, `{"type":"launch","agent"}` / `{"type":"attach","session"}` in (acknowledged with `{"type":"agent_session","session","agent"}`), then durable `{"type":"agent_event","index","event",...}` log entries, ephemeral `{"type":"agent_delta","kind","content","reply"}` streaming chunks, and the `input_required` / `input_cancelled` wait frames answered by `{"type":"input_response","token","text"}`; `{"type":"cancel"}` fires turn-cancel | @@ -61,6 +62,8 @@ At startup the server resolves the gateway endpoint: a live `gateway.json` conne A background heartbeat polls the gateway's `GET /health` every five seconds and reports transitions on the status bus: "Gateway unreachable" when the gateway stops answering, "Connected to gateway" when it comes back. While the gateway is known down, `GET /v1/models` answers 502 `gateway_unreachable` instead of waiting on a dead connection, and the Model menu's `chat_ready` reads false. A reconnect re-fetches the model catalog and pushes it to every `/ws` session as a `{"type":"models",...}` frame, so a UI that booted during the outage refreshes its model picker by itself. Once an endpoint has resolved, the server boots and serves the UI whether or not the gateway has ever answered. +An embedding host can publish a local Gateway replacement only by presenting `shared_sidecar::ValidatedConnection`; raw connection files are not accepted. The server publishes the HTTP client, model client, endpoint, bearer, generation, and validated process identity together as one immutable snapshot, so long-lived consumers never observe mixed replacement state. Explicitly configured LAN gateways have no local process identity and are never supervised or stopped by the desktop shell. + ## UI development The chat UI is TypeScript under `ui/src/`, bundled by esbuild. Building the crate requires Node.js 22: run `npm ci` in `ui/` once per checkout. Every `cargo build` runs the UI build through the crate's `build.rs` (via the shared `build-ui` helper), writing the bundle to `$OUT_DIR/ui-dist/` - never into the repository. Debug builds read the bundle from disk on every request; release builds minify and embed it into the binary. `ui/node_modules/` and `ui/dist/` are gitignored. @@ -69,7 +72,7 @@ The workflow: edit the TypeScript, then `cargo build` (or `cargo run -p workshop `npm run typecheck` runs `tsc --noEmit`; esbuild strips types without checking them, so the typecheck is advisory. `npm test` runs `node --test`, which discovers every test under `ui/test/` plus any colocated `src/**/*.test.mjs` files; the suite includes a jsdom smoke test that imports the built `dist/app.js` and asserts the workbench mounts (run `npm run build` first). -The chat surface is the agent-session panel (`ui/src/ui/agent-session-view.ts`), rendered from the durable event stream over `GET /agents/ws`. Its input carries the push-to-talk mic (`ui/src/ui/stt.ts`): dictation streams PCM over the same-origin `/stt` WebSocket and splices the transcript into the input at the cursor, and the mic is gated by `GET /stt/capability` (`gpu` and `engine` flags) and by the pending input wait, so a refused click names its reason on the status bar. The Workshop listener relays both routes to the authenticated gateway-owned STT endpoints, keeping the gateway key out of the browser while the gateway retains the engine and active-profile lifecycle. `ui/style.css` carries the workshop shell (tree, panels, dictation UI, status bar) and overrides. +The chat surface is the agent-session panel (`ui/src/ui/agent-session-view.ts`), rendered from the durable event stream over `GET /agents/ws`. Its input carries the push-to-talk mic (`ui/src/ui/stt.ts`): `SpeechCaptureService` produces little-endian mono PCM16 at 24 kHz, `RealtimeTranscriptionService` speaks the transcription subset through the same-origin `/v1/realtime` relay, and the view replaces one reversible editor range with live hypothesis snapshots until completion. The mic is gated by the pending input wait, and connection or capture failures are local recoverable status messages. The Workshop never reads speech payloads or owns model lifecycle; the gateway key stays in the server process. `ui/style.css` carries the workshop shell (tree, panels, dictation UI, status bar) and overrides. The status bar at the bottom of the window renders the observer's `{"type":"status",...}` frames (`ui/src/ui/status-bar.ts`): the label as the bar text, the description as the tooltip, error frames in a distinct color. Debug-severity frames are internal instrumentation and never touch the text. The right slot holds a `` bar while a frame carries progress, and an activity LED otherwise: a small circle that pulses green on gateway traffic and amber on dictation activity (green wins when both coincide), lit for one pulse window per frame and faded by a CSS transition. The bar's colors, glow radii, and pulse window are CSS custom properties (`--led-green`, `--led-amber`, `--led-off`, `--led-glow-radius`, `--led-pulse-ms`, `--progress-fill`, `--progress-glow`, ...) at the top of `ui/style.css`. diff --git a/crates/workshop-server/module-ceilings.toml b/crates/workshop-server/module-ceilings.toml index 6397da7b..31ecaca5 100644 --- a/crates/workshop-server/module-ceilings.toml +++ b/crates/workshop-server/module-ceilings.toml @@ -38,7 +38,13 @@ # re-record (the slack absorbed it), and the chat-relay excision's doc # edit here is net zero. "backoff.rs" = 235 -"catalog.rs" = 118 +"catalog.rs" = 97 +# New module: the chat-capable catalog subset, its shared predicate, and +# generation changes consumed by agent-session supervisors. +"catalog/chat.rs" = 83 +# New module: catalog retention and lag behavior tests moved intact when +# chat-facing publication became its own responsibility. +"catalog/tests.rs" = 38 # Grew by a doc line recording the empty-`base_url` contract: no # default is filled, and an empty value is the not-explicit signal # endpoint resolution reads. @@ -58,9 +64,17 @@ # `ForwardedResponse`, and the `forward` relay the /gateway/api route # calls - one more request shape on the same gateway HTTP client. Shrank # in the chat-relay excision: the chat completion methods and `ChatStream` -# left with their tests. Grew by the authenticated gateway WebSocket -# connector used by the same-origin STT relay. -"gateway.rs" = 1295 +# left with their tests. Shrank again when authenticated WebSocket +# connection mechanics moved to their own responsibility module. +"gateway.rs" = 1235 +# New module: one atomically replaceable Gateway URL and bearer generation +# shared by HTTP, Realtime, progress, heartbeat, and agent model clients. +"gateway_binding.rs" = 267 +# Capability-only publication and coherent immutable snapshot coverage. +"gateway_binding/tests.rs" = 130 +# Split from gateway.rs: fixed-target authenticated WebSocket connections +# for the Realtime relay path. +"gateway/socket.rs" = 94 # New module: the gateway progress subscriber, importing the gateway's # /admin/progress event stream into the workshop ProgressHub as a # RemoteOperation while the heartbeat reads the gateway as reachable. @@ -68,10 +82,21 @@ # malformed-event skip and stream-end resubscribe tests) - the recorded # ceiling had also been set four lines under the module's measured size. "gateway_progress.rs" = 466 +# Operation-lifecycle coverage split from the subscriber module so its +# operation-id, SSE-lifetime, and interleaving fixture stays isolated. +"gateway_progress/tests/lifecycle.rs" = 77 +# Replacement-generation coverage split from the subscriber module. +"gateway_progress/tests/recovery.rs" = 38 # Grew by the join-time status recompute: the transition-label constants and # the join_status helper (a late-joining session's line comes from the # current probe, not a stale retained announcement) plus their tests. "heartbeat.rs" = 969 +# Gateway catalog and profile refresh is independent of probe scheduling. +"heartbeat/refresh.rs" = 117 +# Simultaneous-startup convergence fixtures split from the heartbeat loop. +"heartbeat/tests/startup_convergence.rs" = 214 +# Endpoint-generation wake and credential replacement coverage. +"heartbeat/tests/recovery.rs" = 59 # New module: the user-input wait machinery - the WaitRegistry of # single-use cryptographic wait tokens, the Workshop's user_input Tool # (trusted structured output; a drop guard turns every dying wait into a @@ -125,10 +150,14 @@ # the count is its in-file unit tests. Grew by the review round's branch # coverage: the probe-I/O-failure degradation, the no-run-directory skip, # and the explicitly-configured-default-URL pin - same responsibility, -# more tests. -"resolve.rs" = 553 +# more tests. Grew by retaining a validated sidecar identity in the resolved +# value and proving the initial immutable binding receives it. +"resolve.rs" = 588 "routes.rs" = 8 -"routes/assets.rs" = 117 +# Asset route handlers and their tests are separate responsibilities. The +# split lowers the production module and records the test module independently. +"routes/assets.rs" = 62 +"routes/assets/tests.rs" = 93 # Grew in the chat-relay excision by the POST /chat absence pin (404), # while the route itself left. "routes/chat.rs" = 72 @@ -137,20 +166,19 @@ # Grew by the same-origin config SPA proxy (the index and asset routes, # the shared `proxy_config_asset` relay, and the dot-segment refusal); # the growth commit missed this re-record, banked here at measured size. -"routes/gateway_config.rs" = 390 +# Its route/forwarding implementation is now separate from route tests. +"routes/gateway_config.rs" = 190 +"routes/gateway_config/tests.rs" = 243 +# Replacement snapshot coverage split from the proxy's baseline tests. +"routes/gateway_config/tests/recovery.rs" = 55 "routes/health.rs" = 50 -# New module: the same-origin capability and WebSocket relay from the -# Workshop listener to the gateway-owned STT routes. -"routes/stt.rs" = 298 +# New module: the same-origin, payload-opaque Realtime transcription relay, +# including bounded transport and hop-local control-frame ownership. +"routes/realtime.rs" = 181 "routes/workspace.rs" = 20 -# Grew by the gateway-endpoint wiring: `spawn` resolves the endpoint -# (connection file first, explicit config second) before the server -# thread starts, and `spawn_with_routes` takes the host's already-resolved -# endpoint - the same spawn-surface responsibility, one parameter and its -# documentation more. The resolution logic itself lives in resolve.rs. -# Grew again by three lines as the remaining shutdown and bind tests -# moved onto the discovery-bypassing `spawn_with_grace` - a test never -# consults the real run directory. +# `spawn` resolves the endpoint before the server thread starts. +# Tests use the discovery-bypassing fixture so they never consult the real +# run directory. "serve.rs" = 606 # Shrank in the chat-relay excision: the socket keeps the menu events, # boot snapshots, and bus forwarding; the chat multiplexing left whole. @@ -174,7 +202,23 @@ # round (the push_failure that releases the status bar's sustained # Thinking LED, which only on_assistant_reply's idle otherwise clears) # and its pinning test. -"session_agents.rs" = 1100 +"session_agents.rs" = 1028 +# Old accepted-turn and cancellation-provenance branches left this module; +# it now publishes typed events and retains only current-run cancellation. +"session_agents/lifecycle.rs" = 95 +# One agent supervisor now only reduces collected events and dispatches +# their typed effects. +"session_agents/supervisor.rs" = 71 +# Catalog generation collection classifies snapshots without deciding effects. +"session_agents/supervisor/catalog.rs" = 55 +# Typed collection of lifecycle, catalog, Gateway, and run-completion events. +"session_agents/supervisor/events.rs" = 137 +# Reducer-selected cancellation, relaunch, history, and close execution. +"session_agents/supervisor/effects.rs" = 297 +# Pure supervisor event reducer used by the event collection loop. +"session_agents/supervisor/transition.rs" = 431 +# Exhaustive event and effect tables plus exactly-once settlement invariants. +"session_agents/supervisor/transition/tests.rs" = 336 # New module: the /agents/ws socket - one select! loop owning the # socket, the launch/attach/input_response/cancel frame handling, the # cursor-driven durable event drain, and the reconnect replay-and-resend diff --git a/crates/workshop-server/src/app.rs b/crates/workshop-server/src/app.rs index 7733cb16..600a2b60 100644 --- a/crates/workshop-server/src/app.rs +++ b/crates/workshop-server/src/app.rs @@ -11,7 +11,8 @@ use crate::backoff::ReconnectBackoff; use crate::catalog::CatalogBus; use crate::config::Config; use crate::deadline::{DEFAULT_DEADLINE, with_deadline}; -use crate::gateway::{GatewayClient, GatewayError}; +use crate::gateway::GatewayError; +use crate::gateway_binding::{GatewayBinding, GatewaySnapshot, GatewayUpdater}; use crate::heartbeat::GatewayHealth; use crate::menu::MenuBus; use crate::push::Push; @@ -29,7 +30,7 @@ pub const DEFAULT_ADDR: &str = "127.0.0.1:7910"; /// workspace state, and the agent-session registry. #[derive(Debug, Clone)] pub struct AppState { - pub(crate) gateway: GatewayClient, + pub(crate) gateway: GatewayBinding, pub(crate) status: StatusBus, pub(crate) progress: Arc, pub(crate) health: GatewayHealth, @@ -76,12 +77,27 @@ impl AppState { Push::new(self.status.clone(), self.catalog.clone(), self.menu.clone()) } - /// The gateway client, shared with the heartbeat and the relay routes. + /// One atomic Gateway endpoint and credential generation. + pub(crate) fn gateway_snapshot(&self) -> Arc { + self.gateway.snapshot() + } + + /// A clone of the currently published Gateway HTTP client. #[must_use] - pub fn gateway_client(&self) -> &GatewayClient { + pub fn gateway_client(&self) -> crate::GatewayClient { + self.gateway_snapshot().client().clone() + } + + /// The replaceable Gateway binding shared with long-lived tasks. + pub(crate) fn gateway_binding(&self) -> &GatewayBinding { &self.gateway } + /// The restricted local-sidecar replacement handle for an embedding host. + pub(crate) fn gateway_updater(&self) -> GatewayUpdater { + self.gateway.updater() + } + /// Shared gateway reachability, published by the heartbeat; the /// gateway-dependent routes read it to short-circuit while the gateway /// is down. @@ -154,15 +170,19 @@ pub fn state_with_gateway( // Startup phases are reported as they run; with no client connected // yet these land on an empty bus, ready for the first session. crate::resolve::report(gateway, &push); - let client = - GatewayClient::new(gateway.base_url(), gateway.api_key()).map_err(StateError::Gateway)?; + let gateway_binding = GatewayBinding::new_with_identity( + gateway.base_url(), + gateway.api_key(), + gateway.identity().cloned(), + ) + .map_err(StateError::Gateway)?; let progress = Arc::new(ProgressHub::new()); let backoff = ReconnectBackoff::new(); let workspace = Workspace::new(); let agents = AgentSessions::new( config.agents.path.clone(), config.server.state_dir.join("sessions"), - crate::session_agents::model_client(gateway.base_url(), gateway.api_key()), + gateway_binding.clone(), SessionHost { push: push.clone(), backoff: backoff.clone(), @@ -173,7 +193,7 @@ pub fn state_with_gateway( ); push.push_idle(); Ok(AppState { - gateway: client, + gateway: gateway_binding, status, progress, health: GatewayHealth::new(), @@ -217,7 +237,7 @@ pub fn router(state: AppState) -> Router { let api = Router::new() .merge(routes::chat::routes(state.clone())) .merge(crate::session_agents::socket::routes(state.clone())) - .merge(routes::stt::routes(state.clone())) + .merge(routes::realtime::routes(state.clone())) .merge(routes::gateway_config::routes(state)) .merge(with_deadline( routes::workspace::routes(workspace), @@ -231,8 +251,7 @@ pub fn router(state: AppState) -> Router { // The outermost layer on the server's own routes: every response // carries the CSP, error envelopes included, so the shell's // External-origin webview runs under the policy no matter which - // route answered. Routes a host merges through `spawn_with_routes` - // are composed after this layer and sit outside it. + // route answered. .layer(axum::middleware::from_fn(crate::csp::header)) } @@ -332,6 +351,7 @@ mod tests { use axum::routing::get; use super::fixtures::{config_for, spawn_gateway}; + use crate::gateway::GatewayClient; /// Reports whether the request carried an `Authorization` header, so /// the client tests can observe what was sent. diff --git a/crates/workshop-server/src/catalog.rs b/crates/workshop-server/src/catalog.rs index 85b0ac17..767c8b63 100644 --- a/crates/workshop-server/src/catalog.rs +++ b/crates/workshop-server/src/catalog.rs @@ -1,5 +1,5 @@ -//! The model catalog push channel: the gateway's catalog, rebroadcast to -//! every connected `/ws` session as a `{"type":"models",...}` frame. +//! The chat-capable model catalog push channel, rebroadcast to every +//! connected `/ws` session as a `{"type":"models",...}` frame. //! //! The heartbeat republishes the catalog when the gateway comes back //! (unreachable to connected), so a UI that booted while the gateway was @@ -13,10 +13,14 @@ use std::sync::{Arc, Mutex, PoisonError}; -use tokio::sync::broadcast; +use tokio::sync::{broadcast, watch}; use crate::protocol::CatalogPush; +mod chat; +use chat::ChatCatalogBus; +pub(crate) use chat::{ChatCatalog, is_chat_capable}; + /// Ring capacity of the catalog bus. Pushes are rare (one per gateway /// reconnect) and each is a full snapshot, so a handful of slots is /// generous. @@ -28,6 +32,7 @@ const CATALOG_CHANNEL_CAPACITY: usize = 4; pub struct CatalogBus { sender: broadcast::Sender, latest: Arc>>, + chat: ChatCatalogBus, } impl CatalogBus { @@ -36,6 +41,7 @@ impl CatalogBus { Self { sender: broadcast::channel(CATALOG_CHANNEL_CAPACITY).0, latest: Arc::new(Mutex::new(None)), + chat: ChatCatalogBus::new(), } } @@ -55,10 +61,22 @@ impl CatalogBus { .clone() } + /// The current non-empty chat-capable catalog generation. + pub(crate) fn latest_chat(&self) -> Option { + self.chat.latest() + } + + /// Subscribes to chat-capable catalog generation changes. + pub(crate) fn subscribe_chat_generation(&self) -> watch::Receiver { + self.chat.subscribe() + } + /// Broadcasts one catalog. With no subscribers this is a no-op; a slow /// subscriber skips ahead rather than applying backpressure. pub fn publish(&self, models: Vec) { + let models = models.into_iter().filter(is_chat_capable).collect(); let push = CatalogPush { models }; + self.chat.publish(&push.models); // The retained copy (a second owner, hence the clone) is written // before the send, so a session that subscribes after the send // still finds this push as its snapshot. @@ -76,43 +94,4 @@ impl Default for CatalogBus { } #[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn publishing_with_no_subscribers_is_a_no_op() { - let bus = CatalogBus::new(); - bus.publish(vec![serde_json::json!({"id": "test-model"})]); - } - - #[test] - fn the_newest_push_is_retained_for_the_connect_snapshot() { - let bus = CatalogBus::new(); - assert!(bus.latest().is_none(), "an untouched bus has no snapshot"); - bus.publish(vec![serde_json::json!({"id": "old"})]); - bus.publish(vec![serde_json::json!({"id": "new"})]); - let latest = bus.latest().expect("the bus retains the newest push"); - assert_eq!( - latest.models[0]["id"], "new", - "a session connecting now snapshots the newest catalog" - ); - } - - #[tokio::test] - async fn a_lagged_receiver_skips_ahead_instead_of_blocking() { - let bus = CatalogBus::new(); - let mut receiver = bus.subscribe(); - for index in 0..=CATALOG_CHANNEL_CAPACITY { - bus.publish(vec![serde_json::json!({"id": format!("model-{index}")})]); - } - match receiver.recv().await { - Err(broadcast::error::RecvError::Lagged(1)) => {} - other => panic!("expected a lag report of one, got {other:?}"), - } - let resumed = receiver.recv().await.expect("the ring still holds pushes"); - assert_eq!( - resumed.models[0]["id"], "model-1", - "receiving resumes at the oldest retained push" - ); - } -} +mod tests; diff --git a/crates/workshop-server/src/catalog/chat.rs b/crates/workshop-server/src/catalog/chat.rs new file mode 100644 index 00000000..f2b9e54c --- /dev/null +++ b/crates/workshop-server/src/catalog/chat.rs @@ -0,0 +1,83 @@ +//! Chat-capable catalog filtering and generation tracking. + +use std::sync::{Arc, Mutex, PoisonError}; + +use tokio::sync::watch; + +/// One immutable chat-capable catalog generation. +#[derive(Debug, Clone, Default)] +pub(crate) struct ChatCatalog { + /// Monotonically increasing whenever the chat-capable subset changes. + pub(crate) generation: u64, + /// The chat-capable entries for this generation. + pub(crate) models: Vec, +} + +/// Shared retained chat catalog and its generation notification. +#[derive(Debug, Clone)] +pub(super) struct ChatCatalogBus { + latest: Arc>, + generation: watch::Sender, +} + +impl ChatCatalogBus { + /// Creates an empty generation tracker. + pub(super) fn new() -> Self { + Self { + latest: Arc::new(Mutex::new(ChatCatalog::default())), + generation: watch::channel(0).0, + } + } + + /// Returns the current generation only when it can serve chat. + pub(super) fn latest(&self) -> Option { + let chat = self + .latest + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone(); + (!chat.models.is_empty()).then_some(chat) + } + + /// Subscribes to changes of the chat-capable subset. + pub(super) fn subscribe(&self) -> watch::Receiver { + self.generation.subscribe() + } + + /// Replaces the source snapshot, advancing only when its chat subset + /// changes. Transcription-only churn does not disturb chat runs. + pub(super) fn publish(&self, models: &[serde_json::Value]) { + let models: Vec = models + .iter() + .filter(|model| is_chat_capable(model)) + .cloned() + .collect(); + let changed = { + let mut latest = self.latest.lock().unwrap_or_else(PoisonError::into_inner); + if latest.models == models { + None + } else { + latest.generation = latest.generation.wrapping_add(1); + latest.models = models; + Some(latest.generation) + } + }; + if let Some(generation) = changed { + self.generation.send_replace(generation); + } + } +} + +/// Whether one gateway catalog row can back a chat model binding. +pub(crate) fn is_chat_capable(model: &serde_json::Value) -> bool { + let has_id = model + .get("id") + .and_then(serde_json::Value::as_str) + .is_some_and(|id| !id.is_empty()); + let chat_kind = match model.get("kind") { + None => true, + Some(serde_json::Value::String(kind)) => kind == "chat", + Some(_) => false, + }; + has_id && chat_kind +} diff --git a/crates/workshop-server/src/catalog/tests.rs b/crates/workshop-server/src/catalog/tests.rs new file mode 100644 index 00000000..e5d9db16 --- /dev/null +++ b/crates/workshop-server/src/catalog/tests.rs @@ -0,0 +1,38 @@ +use super::*; + +#[tokio::test] +async fn publishing_with_no_subscribers_is_a_no_op() { + let bus = CatalogBus::new(); + bus.publish(vec![serde_json::json!({"id": "test-model"})]); +} + +#[test] +fn the_newest_push_is_retained_for_the_connect_snapshot() { + let bus = CatalogBus::new(); + assert!(bus.latest().is_none(), "an untouched bus has no snapshot"); + bus.publish(vec![serde_json::json!({"id": "old"})]); + bus.publish(vec![serde_json::json!({"id": "new"})]); + let latest = bus.latest().expect("the bus retains the newest push"); + assert_eq!( + latest.models[0]["id"], "new", + "a session connecting now snapshots the newest catalog" + ); +} + +#[tokio::test] +async fn a_lagged_receiver_skips_ahead_instead_of_blocking() { + let bus = CatalogBus::new(); + let mut receiver = bus.subscribe(); + for index in 0..=CATALOG_CHANNEL_CAPACITY { + bus.publish(vec![serde_json::json!({"id": format!("model-{index}")})]); + } + match receiver.recv().await { + Err(broadcast::error::RecvError::Lagged(1)) => {} + other => panic!("expected a lag report of one, got {other:?}"), + } + let resumed = receiver.recv().await.expect("the ring still holds pushes"); + assert_eq!( + resumed.models[0]["id"], "model-1", + "receiving resumes at the oldest retained push" + ); +} diff --git a/crates/workshop-server/src/csp.rs b/crates/workshop-server/src/csp.rs index 5e1b0fed..ba1f9f66 100644 --- a/crates/workshop-server/src/csp.rs +++ b/crates/workshop-server/src/csp.rs @@ -1,6 +1,4 @@ -//! The Content-Security-Policy stamped on every response from the -//! server's own routes (routes a host merges through `spawn_with_routes` -//! are composed after this layer and carry their own layers). +//! The Content-Security-Policy stamped on every server response. //! //! The desktop shell loads the UI as an External-origin Tauri webview, so //! the page's policy is the server's to set: there is no `tauri.conf.json` diff --git a/crates/workshop-server/src/gateway.rs b/crates/workshop-server/src/gateway.rs index ae2fc262..b294eac7 100644 --- a/crates/workshop-server/src/gateway.rs +++ b/crates/workshop-server/src/gateway.rs @@ -14,11 +14,9 @@ use std::time::Duration; use futures_util::stream::{self, Stream, StreamExt}; use serde::Deserialize; -use tokio_tungstenite::tungstenite::client::IntoClientRequest as _; -/// An authenticated WebSocket connection to the gateway's STT stream. -pub(crate) type GatewaySttSocket = - tokio_tungstenite::WebSocketStream>; +mod socket; +pub(crate) use socket::GatewayRealtimeSocket; /// Default bound on a single `GET /health` probe: a gateway that accepts /// the connection but never answers must still read as unreachable, and two @@ -272,8 +270,8 @@ pub enum GatewayError { #[derive(Clone)] pub struct GatewayClient { http: reqwest::Client, - base_url: String, - api_key: String, + pub(crate) base_url: String, + pub(crate) api_key: String, /// Whole-request bound for buffered calls; header-phase bound for /// streaming calls. request_timeout: Duration, @@ -360,64 +358,6 @@ impl GatewayClient { &self.base_url } - /// Opens the gateway's authenticated `/stt` WebSocket. - /// - /// The Workshop browser never receives the gateway key. Its same-origin - /// socket terminates at workshop-server, which uses this connection for - /// the upstream half of the relay. - pub(crate) async fn connect_stt(&self) -> Result { - let mut url = url::Url::parse(&self.base_url) - .map_err(|source| GatewayError::Transport(Box::new(source)))?; - let scheme = match url.scheme() { - "http" => "ws", - "https" => "wss", - scheme => { - return Err(GatewayError::Transport(Box::new(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("gateway URL scheme {scheme:?} cannot carry a WebSocket"), - )))); - } - }; - url.set_scheme(scheme).map_err(|()| { - GatewayError::Transport(Box::new(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "gateway URL scheme cannot be converted to WebSocket", - ))) - })?; - let path = format!("{}/stt", url.path().trim_end_matches('/')); - url.set_path(&path); - url.set_query(None); - url.set_fragment(None); - let mut request = url - .as_str() - .into_client_request() - .map_err(|source| GatewayError::Transport(Box::new(source)))?; - if !self.api_key.is_empty() { - let value = format!("Bearer {}", self.api_key) - .parse() - .map_err(|source| GatewayError::Transport(Box::new(source)))?; - request.headers_mut().insert( - tokio_tungstenite::tungstenite::http::header::AUTHORIZATION, - value, - ); - } - request.headers_mut().insert( - "x-promptforge-workshop-status", - "1".parse() - .map_err(|source| GatewayError::Transport(Box::new(source)))?, - ); - match tokio::time::timeout( - self.request_timeout, - tokio_tungstenite::connect_async(request), - ) - .await - { - Ok(Ok((socket, _response))) => Ok(socket), - Ok(Err(source)) => Err(GatewayError::Transport(Box::new(source))), - Err(elapsed) => Err(GatewayError::Transport(Box::new(elapsed))), - } - } - /// Forwards one request to the gateway: `method` on /// `path_and_query`, with an optional JSON `body`, authenticated /// with the client's bearer key. Only the wait for the response diff --git a/crates/workshop-server/src/gateway/socket.rs b/crates/workshop-server/src/gateway/socket.rs new file mode 100644 index 00000000..07c89194 --- /dev/null +++ b/crates/workshop-server/src/gateway/socket.rs @@ -0,0 +1,69 @@ +//! Authenticated WebSocket connections from Workshop to Gateway. + +use tokio_tungstenite::tungstenite::client::IntoClientRequest as _; + +use super::{GatewayClient, GatewayError}; + +type GatewaySocket = + tokio_tungstenite::WebSocketStream>; + +/// An authenticated WebSocket connection to Gateway Realtime transcription. +pub(crate) type GatewayRealtimeSocket = GatewaySocket; + +impl GatewayClient { + /// Opens the gateway's authenticated Realtime transcription socket. + /// + /// The target is fixed to `/v1/realtime?intent=transcription`; browser + /// query parameters and handshake policy headers never cross the relay. + pub(crate) async fn connect_realtime(&self) -> Result { + self.connect_socket().await + } + + async fn connect_socket(&self) -> Result { + let mut url = url::Url::parse(&self.base_url) + .map_err(|source| GatewayError::Transport(Box::new(source)))?; + let scheme = match url.scheme() { + "http" => "ws", + "https" => "wss", + scheme => { + return Err(GatewayError::Transport(Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("gateway URL scheme {scheme:?} cannot carry a WebSocket"), + )))); + } + }; + url.set_scheme(scheme).map_err(|()| { + GatewayError::Transport(Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "gateway URL scheme cannot be converted to WebSocket", + ))) + })?; + let path = format!("{}/v1/realtime", url.path().trim_end_matches('/')); + url.set_path(&path); + url.set_query(Some("intent=transcription")); + url.set_fragment(None); + let mut request = url + .as_str() + .into_client_request() + .map_err(|source| GatewayError::Transport(Box::new(source)))?; + if !self.api_key.is_empty() { + let value = format!("Bearer {}", self.api_key) + .parse() + .map_err(|source| GatewayError::Transport(Box::new(source)))?; + request.headers_mut().insert( + tokio_tungstenite::tungstenite::http::header::AUTHORIZATION, + value, + ); + } + match tokio::time::timeout( + self.request_timeout, + tokio_tungstenite::connect_async(request), + ) + .await + { + Ok(Ok((socket, _response))) => Ok(socket), + Ok(Err(source)) => Err(GatewayError::Transport(Box::new(source))), + Err(elapsed) => Err(GatewayError::Transport(Box::new(elapsed))), + } + } +} diff --git a/crates/workshop-server/src/gateway_binding.rs b/crates/workshop-server/src/gateway_binding.rs new file mode 100644 index 00000000..a1049d38 --- /dev/null +++ b/crates/workshop-server/src/gateway_binding.rs @@ -0,0 +1,273 @@ +//! Atomically replaceable Gateway endpoint and credential state. +//! +//! Every Gateway-dependent Workshop path loads one immutable snapshot +//! containing the HTTP client, model client, base URL, bearer, and generation. +//! A local-sidecar replacement builds the complete next snapshot before one +//! atomic store, then notifies long-lived tasks to reconnect. Explicitly +//! configured endpoints never receive an updater from the desktop shell. + +use std::fmt; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; + +use arc_swap::ArcSwap; +use promptforge_model_client::client::{ + GatewayClient as ModelClient, GatewayEndpoint, SecretString, +}; +use tokio::sync::watch; + +use crate::gateway::{GatewayClient, GatewayError}; + +/// One immutable generation of every Gateway client credential. +pub(crate) struct GatewaySnapshot { + /// HTTP and Realtime client used by Workshop routes and the heartbeat. + client: GatewayClient, + /// Normalized Gateway base URL paired with both clients. + base_url: String, + /// Bearer paired with `client`, retained for the progress subscriber. + api_key: String, + /// Agent completion client built from the same URL and bearer. + model_client: Option, + /// Monotonic generation assigned before this snapshot is published. + generation: u64, + /// Proven local Gateway boot, absent for an explicitly configured endpoint. + identity: Option, +} + +impl fmt::Debug for GatewaySnapshot { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("GatewaySnapshot") + .field("client", &self.client) + .field("base_url", &self.base_url) + .field("api_key", &"") + .field("model_client", &"") + .field("generation", &self.generation) + .field("identity", &self.identity) + .finish_non_exhaustive() + } +} + +impl GatewaySnapshot { + /// The HTTP and Realtime client in this generation. + pub(crate) fn client(&self) -> &GatewayClient { + &self.client + } + + /// The agent model client from the same endpoint and credential pair. + pub(crate) fn model_client(&self) -> Option { + self.model_client.clone() + } + + /// The Gateway base URL in this generation. + pub(crate) fn base_url(&self) -> &str { + &self.base_url + } + + /// The Gateway bearer in this generation. + pub(crate) fn api_key(&self) -> &str { + &self.api_key + } + + /// This snapshot's monotonic generation. + pub(crate) fn generation(&self) -> u64 { + self.generation + } +} + +/// Shared atomic Gateway snapshot and replacement notification. +#[derive(Clone)] +pub(crate) struct GatewayBinding { + current: Arc>, + next_generation: Arc, + changed: watch::Sender, + replacement: Arc>, +} + +impl fmt::Debug for GatewayBinding { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("GatewayBinding") + .field("current", &self.snapshot()) + .finish() + } +} + +impl GatewayBinding { + /// Builds generation zero from one endpoint and credential pair. + #[cfg(test)] + pub(crate) fn new(base_url: &str, api_key: &str) -> Result { + Self::new_with_identity(base_url, api_key, None) + } + + /// Builds generation zero with an optional validated local identity. + pub(crate) fn new_with_identity( + base_url: &str, + api_key: &str, + identity: Option, + ) -> Result { + let snapshot = Arc::new(build_snapshot(base_url, api_key, 0, identity)?); + Ok(Self { + current: Arc::new(ArcSwap::from(snapshot)), + next_generation: Arc::new(AtomicU64::new(1)), + changed: watch::channel(0).0, + replacement: Arc::new(Mutex::new(())), + }) + } + + /// Builds a binding around a client carrying test-specific timeouts. + pub(crate) fn from_client(client: GatewayClient) -> Self { + let model_client = model_client(&client.base_url, &client.api_key); + let base_url = client.base_url.clone(); + let api_key = client.api_key.clone(); + let snapshot = Arc::new(GatewaySnapshot { + client, + base_url, + api_key, + model_client, + generation: 0, + identity: None, + }); + Self { + current: Arc::new(ArcSwap::from(snapshot)), + next_generation: Arc::new(AtomicU64::new(1)), + changed: watch::channel(0).0, + replacement: Arc::new(Mutex::new(())), + } + } + + /// Loads one endpoint and credential generation atomically. + pub(crate) fn snapshot(&self) -> Arc { + self.current.load_full() + } + + /// Subscribes to replacements after loading the current generation. + pub(crate) fn subscribe(&self) -> watch::Receiver { + self.changed.subscribe() + } + + /// The currently published generation. + pub(crate) fn generation(&self) -> u64 { + self.snapshot().generation() + } + + /// Builds and atomically publishes a replacement, then wakes consumers. + #[cfg(test)] + pub(crate) fn replace(&self, base_url: &str, api_key: &str) -> Result<(), GatewayError> { + self.replace_with_identity(base_url, api_key, None) + } + + /// Builds and atomically publishes a complete replacement generation. + fn replace_with_identity( + &self, + base_url: &str, + api_key: &str, + identity: Option, + ) -> Result<(), GatewayError> { + let _replacement = self + .replacement + .lock() + .unwrap_or_else(PoisonError::into_inner); + let generation = self.next_generation.fetch_add(1, Ordering::SeqCst); + let snapshot = Arc::new(build_snapshot(base_url, api_key, generation, identity)?); + self.current.store(snapshot); + self.changed.send_replace(generation); + Ok(()) + } + + /// Creates the restricted handle the desktop host uses for sidecar updates. + pub(crate) fn updater(&self) -> GatewayUpdater { + GatewayUpdater { + binding: self.clone(), + } + } +} + +/// A restricted publisher for a replacement validated local sidecar. +/// +/// Raw connection files cannot cross this publication boundary: +/// +/// ```compile_fail +/// use shared_sidecar::ConnectionFile; +/// +/// # fn publish( +/// # updater: &workshop_server::GatewayUpdater, +/// # raw: &ConnectionFile, +/// # ) -> Result<(), workshop_server::GatewayError> { +/// updater.replace_sidecar(raw) +/// # } +/// ``` +#[derive(Clone)] +pub struct GatewayUpdater { + binding: GatewayBinding, +} + +impl fmt::Debug for GatewayUpdater { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("GatewayUpdater") + .finish_non_exhaustive() + } +} + +impl GatewayUpdater { + /// Atomically replaces the local Gateway port and bearer, waking every + /// long-lived Workshop consumer only after the complete snapshot is live. + /// + /// # Errors + /// Returns [`GatewayError::Build`] if the replacement HTTP client cannot + /// initialize. + pub fn replace_sidecar( + &self, + connection: &shared_sidecar::ValidatedConnection, + ) -> Result<(), GatewayError> { + self.binding.replace_with_identity( + &format!("http://127.0.0.1:{}", connection.port()), + connection.api_key(), + Some(connection.clone()), + ) + } +} + +/// Builds all clients before publication so URL and bearer never tear. +fn build_snapshot( + base_url: &str, + api_key: &str, + generation: u64, + identity: Option, +) -> Result { + let client = GatewayClient::new(base_url, api_key)?; + let base_url = client.base_url().to_owned(); + let model_client = model_client(&base_url, api_key); + Ok(GatewaySnapshot { + client, + base_url, + api_key: api_key.to_owned(), + model_client, + generation, + identity, + }) +} + +/// Builds the agent model client carried in a Gateway snapshot. +pub(crate) fn model_client(base_url: &str, api_key: &str) -> Option { + let key = match SecretString::new(api_key) { + Ok(key) => key, + Err(error) => { + tracing::warn!(%error, "agent sessions disabled: gateway API key unusable"); + return None; + } + }; + let root = format!("{}/v1", base_url.trim_end_matches('/')); + let endpoint = match GatewayEndpoint::new(&root) { + Ok(endpoint) => endpoint, + Err(error) => { + tracing::warn!(%error, "agent sessions disabled: gateway URL unusable"); + return None; + } + }; + Some(ModelClient::new(endpoint, key)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/workshop-server/src/gateway_binding/tests.rs b/crates/workshop-server/src/gateway_binding/tests.rs new file mode 100644 index 00000000..affaa798 --- /dev/null +++ b/crates/workshop-server/src/gateway_binding/tests.rs @@ -0,0 +1,119 @@ +use super::*; + +use std::io::{Read, Write as _}; +use std::net::TcpListener; + +fn validated_connection( + port: u16, + api_key: &str, + epoch: u64, + started_at: &str, +) -> shared_sidecar::ValidatedConnection { + shared_sidecar::ValidatedConnection::validate_for_test(shared_sidecar::ConnectionFile { + port, + api_key: api_key.to_owned(), + pid: std::process::id(), + epoch, + version: "test".to_owned(), + started_at: started_at.to_owned(), + }) + .expect("the test connection validates") +} + +fn fixture_gateway(expected_key: &'static str) -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fixture"); + let port = listener.local_addr().expect("fixture address").port(); + std::thread::spawn(move || { + while let Ok((mut stream, _)) = listener.accept() { + for _ in 0..2 { + let mut buffer = [0_u8; 1024]; + let Ok(read) = stream.read(&mut buffer) else { + break; + }; + let request = String::from_utf8_lossy(&buffer[..read]); + let accepted = request.starts_with("GET /health ") + || request.contains(&format!("Authorization: Bearer {expected_key}\r\n")); + let response = if accepted { + &b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"[..] + } else { + &b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"[..] + }; + if stream.write_all(response).is_err() { + break; + } + } + } + }); + port +} + +#[test] +fn capability_replacement_publishes_one_coherent_snapshot() { + let binding = GatewayBinding::new("http://127.0.0.1:54375", "old-key").expect("binding builds"); + let old = binding.snapshot(); + let port = fixture_gateway("new-key"); + let validated = validated_connection(port, "new-key", 1_778_000_001, "2026-09-07T18:00:01Z"); + + binding + .updater() + .replace_sidecar(&validated) + .expect("replacement builds"); + let new = binding.snapshot(); + + assert_eq!(old.client().base_url, "http://127.0.0.1:54375"); + assert_eq!(old.client().api_key, "old-key"); + assert_eq!(old.base_url(), "http://127.0.0.1:54375"); + assert_eq!(old.api_key(), "old-key"); + assert!( + old.identity.is_none(), + "configured gateways have no local identity" + ); + assert_eq!(new.client().base_url, format!("http://127.0.0.1:{port}")); + assert_eq!(new.client().api_key, "new-key"); + assert_eq!(new.base_url(), format!("http://127.0.0.1:{port}")); + assert_eq!(new.api_key(), "new-key"); + assert!( + format!("{:?}", new.model_client().expect("the model client builds")) + .contains(&format!("http://127.0.0.1:{port}/v1")), + "the model client carries the same endpoint" + ); + let identity = new + .identity + .as_ref() + .expect("the validated identity is published"); + assert_eq!(identity, &validated); + assert!(new.generation() > old.generation()); + assert_eq!(binding.generation(), new.generation()); +} + +#[test] +fn same_port_and_key_new_boot_still_publishes_a_new_identity() { + let port = fixture_gateway("stable-key"); + let first = validated_connection(port, "stable-key", 1_778_000_001, "2026-09-07T18:00:01Z"); + let replacement = + validated_connection(port, "stable-key", 1_778_000_002, "2026-09-07T18:00:02Z"); + let binding = + GatewayBinding::new("http://127.0.0.1:54375", "stable-key").expect("binding builds"); + binding + .updater() + .replace_sidecar(&first) + .expect("the first identity publishes"); + let first_snapshot = binding.snapshot(); + binding + .updater() + .replace_sidecar(&replacement) + .expect("the replacement identity publishes"); + let replacement_snapshot = binding.snapshot(); + + assert_eq!(replacement_snapshot.base_url(), first_snapshot.base_url()); + assert_eq!(replacement_snapshot.api_key(), first_snapshot.api_key()); + assert!( + replacement_snapshot.generation() > first_snapshot.generation(), + "identity replacement advances the generation even with a stable endpoint and bearer" + ); + assert_eq!( + replacement_snapshot.identity.as_ref(), + Some(&replacement), + "the snapshot carries the new validated boot" + ); +} diff --git a/crates/workshop-server/src/gateway_progress.rs b/crates/workshop-server/src/gateway_progress.rs index f1063a27..a303352d 100644 --- a/crates/workshop-server/src/gateway_progress.rs +++ b/crates/workshop-server/src/gateway_progress.rs @@ -9,12 +9,14 @@ //! graceful-shutdown signal, and driven by the shared [`GatewayHealth`] //! verdict rather than by probes of its own. It subscribes while the //! gateway reads reachable and idles while it does not; a reconnect -//! resubscribes, and each subscription attaches a fresh import, so a -//! gateway that flaps never stacks duplicate remote state on the hub. +//! resubscribes, and each subscription tracks one import per upstream +//! operation id, so interleaved work stays separate and a finished operation +//! detaches without closing the long-lived event stream. //! When the subscription drops - a lost connection or an unreachable //! verdict - the import detaches with it, because progress from a gateway //! the workshop can no longer hear is stale, not informative. +use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; @@ -22,8 +24,9 @@ use futures_util::StreamExt; use tokio::sync::oneshot; use promptforge_model_client::model::subscribe_progress; -use shared_progress::{ProgressHub, RemoteOperation}; +use shared_progress::{EventState, OperationId, ProgressHub, RemoteOperation}; +use crate::gateway_binding::GatewayBinding; use crate::heartbeat::GatewayHealth; /// How long a resubscribe waits when the stream ended while the gateway @@ -60,33 +63,23 @@ impl Subscriber { /// reachable. #[must_use] pub(crate) fn spawn( - base_url: String, - api_key: String, + gateway: GatewayBinding, hub: Arc, health: GatewayHealth, ) -> Subscriber { - spawn_with_delay(base_url, api_key, hub, health, RESUBSCRIBE_DELAY) + spawn_with_delay(gateway, hub, health, RESUBSCRIBE_DELAY) } /// [`spawn`] with the resubscribe delay injected, so tests can shorten it. fn spawn_with_delay( - base_url: String, - api_key: String, + gateway: GatewayBinding, hub: Arc, health: GatewayHealth, resubscribe_delay: Duration, ) -> Subscriber { let (stop, mut stopped) = oneshot::channel(); let task = tokio::spawn(async move { - run( - &base_url, - &api_key, - &hub, - &health, - resubscribe_delay, - &mut stopped, - ) - .await; + run(&gateway, &hub, &health, resubscribe_delay, &mut stopped).await; }); Subscriber { stop: Some(stop), @@ -95,22 +88,29 @@ fn spawn_with_delay( } /// The subscription loop: idle while the gateway is unreachable, and while -/// reachable hold one subscription whose events drive one -/// [`RemoteOperation`]. The stop signal wins every select, so shutdown -/// never waits out a stream read, a connect, or a resubscribe delay. +/// reachable hold one subscription whose events drive operation-id-keyed +/// [`RemoteOperation`] imports. An operation-level terminal event +/// detaches that import while the subscription remains open. The stop +/// signal wins every select, so shutdown never waits out a stream read, a +/// connect, or a resubscribe delay. async fn run( - base_url: &str, - api_key: &str, + gateway: &GatewayBinding, hub: &Arc, health: &GatewayHealth, resubscribe_delay: Duration, stop: &mut oneshot::Receiver<()>, ) { let mut reachable = health.subscribe(); - loop { + let mut gateway_changed = gateway.subscribe(); + 'reconnect: loop { while !*reachable.borrow_and_update() { tokio::select! { _ = &mut *stop => return, + changed = gateway_changed.changed() => { + if changed.is_err() { + return; + } + } changed = reachable.changed() => { // The sender lives in AppState for the process // lifetime, so a closed watch means shutdown. @@ -120,30 +120,58 @@ async fn run( } } } + let snapshot = gateway.snapshot(); let stream = tokio::select! { _ = &mut *stop => return, _ = reachable.changed() => continue, - result = subscribe_progress(base_url, api_key) => match result { + changed = gateway_changed.changed() => { + if changed.is_err() { + return; + } + continue; + } + result = subscribe_progress(snapshot.base_url(), snapshot.api_key()) => match result { Ok(stream) => stream, Err(error) => { tracing::warn!(%error, "gateway progress subscription failed"); tokio::select! { _ = &mut *stop => return, _ = reachable.changed() => {} + changed = gateway_changed.changed() => { + if changed.is_err() { + return; + } + } () = tokio::time::sleep(resubscribe_delay) => {} } continue; } }, }; - let remote = RemoteOperation::attach(hub); + let mut remotes: HashMap = HashMap::new(); tokio::pin!(stream); loop { tokio::select! { _ = &mut *stop => return, _ = reachable.changed() => break, + changed = gateway_changed.changed() => { + if changed.is_err() { + return; + } + continue 'reconnect; + } item = stream.next() => match item { - Some(Ok(event)) => remote.apply(&event), + Some(Ok(event)) => { + let operation = event.operation; + if matches!(event.state, EventState::OperationFinished) { + remotes.remove(&operation); + continue; + } + remotes + .entry(operation) + .or_insert_with(|| RemoteOperation::attach(hub)) + .apply(&event); + } // One malformed event or a terminal read failure; the // stream itself decides which by continuing or ending. Some(Err(error)) => { @@ -153,11 +181,16 @@ async fn run( } } } - drop(remote); + drop(remotes); if *reachable.borrow_and_update() { tokio::select! { _ = &mut *stop => return, _ = reachable.changed() => {} + changed = gateway_changed.changed() => { + if changed.is_err() { + return; + } + } () = tokio::time::sleep(resubscribe_delay) => {} } } @@ -182,6 +215,11 @@ mod tests { use crate::app::fixtures::spawn_gateway; + /// A replaceable binding for one mock Gateway. + fn binding(base_url: &str) -> GatewayBinding { + GatewayBinding::new(base_url, "").expect("the test binding builds") + } + /// A mock `GET /admin/progress`: every payload published to the feed /// streams to every connected subscriber as an SSE `data:` frame, and /// `connections` counts how often the endpoint was hit. The receiver @@ -300,12 +338,7 @@ mod tests { let base_url = spawn_gateway(Arc::clone(&mock).router()).await; let hub = Arc::new(ProgressHub::new()); // The flag starts optimistic, so the subscriber connects at once. - let subscriber = spawn( - base_url, - String::new(), - Arc::clone(&hub), - GatewayHealth::new(), - ); + let subscriber = spawn(binding(&base_url), Arc::clone(&hub), GatewayHealth::new()); wait_for_connections(&mock, 1).await; mock.send(event_json( @@ -331,12 +364,7 @@ mod tests { let mock = Arc::new(MockProgress::new()); let base_url = spawn_gateway(Arc::clone(&mock).router()).await; let hub = Arc::new(ProgressHub::new()); - let subscriber = spawn( - base_url, - String::new(), - Arc::clone(&hub), - GatewayHealth::new(), - ); + let subscriber = spawn(binding(&base_url), Arc::clone(&hub), GatewayHealth::new()); wait_for_connections(&mock, 1).await; mock.send(event_json( @@ -369,8 +397,7 @@ mod tests { let hub = Arc::new(ProgressHub::new()); let delay = Duration::from_millis(50); let subscriber = spawn_with_delay( - base_url, - String::new(), + binding(&base_url), Arc::clone(&hub), GatewayHealth::new(), delay, @@ -403,7 +430,7 @@ mod tests { let hub = Arc::new(ProgressHub::new()); let health = GatewayHealth::new(); health.publish(false); - let subscriber = spawn(base_url, String::new(), Arc::clone(&hub), health.clone()); + let subscriber = spawn(binding(&base_url), Arc::clone(&hub), health.clone()); let quiet = tokio::time::timeout(Duration::from_millis(200), async { wait_for_connections(&mock, 1).await; @@ -426,7 +453,7 @@ mod tests { let base_url = spawn_gateway(Arc::clone(&mock).router()).await; let hub = Arc::new(ProgressHub::new()); let health = GatewayHealth::new(); - let subscriber = spawn(base_url, String::new(), Arc::clone(&hub), health.clone()); + let subscriber = spawn(binding(&base_url), Arc::clone(&hub), health.clone()); wait_for_connections(&mock, 1).await; mock.send(event_json( @@ -463,4 +490,7 @@ mod tests { ); subscriber.shutdown().await; } + + mod lifecycle; + mod recovery; } diff --git a/crates/workshop-server/src/gateway_progress/tests/lifecycle.rs b/crates/workshop-server/src/gateway_progress/tests/lifecycle.rs new file mode 100644 index 00000000..8540f71a --- /dev/null +++ b/crates/workshop-server/src/gateway_progress/tests/lifecycle.rs @@ -0,0 +1,71 @@ +use super::*; + +fn operation_event_json(operation: u64, path: &str, state: &serde_json::Value) -> String { + serde_json::json!({ + "operation": operation, + "path": path, + "label": path, + "state": state, + }) + .to_string() +} + +#[tokio::test] +async fn a_multi_stage_operation_detaches_only_when_the_operation_finishes() { + let mock = Arc::new(MockProgress::new()); + let base_url = spawn_gateway(Arc::clone(&mock).router()).await; + let hub = Arc::new(ProgressHub::new()); + let subscriber = spawn(binding(&base_url), Arc::clone(&hub), GatewayHealth::new()); + + wait_for_connections(&mock, 1).await; + mock.send(event_json( + "loading-profile", + &serde_json::json!({"Begun": {"weight": 1.0}}), + )); + snapshot_where(&hub, |snapshot| snapshot.len() == 1).await; + mock.send(event_json( + "loading-profile", + &serde_json::json!({"Finished": {"ok": true}}), + )); + snapshot_where(&hub, |snapshot| { + snapshot.len() == 1 + && snapshot[0] + .nodes + .iter() + .any(|node| node.path == "loading-profile" && node.finished) + }) + .await; + mock.send(operation_event_json( + 8, + "download", + &serde_json::json!({"Begun": {"weight": 1.0}}), + )); + mock.send(event_json( + "starting-models", + &serde_json::json!({"Begun": {"weight": 5.0}}), + )); + snapshot_where(&hub, |snapshot| { + snapshot.len() == 2 + && snapshot.iter().any(|operation| { + operation + .nodes + .iter() + .any(|node| node.path == "starting-models" && !node.finished) + }) + }) + .await; + mock.send(operation_event_json( + 7, + "", + &serde_json::json!("OperationFinished"), + )); + let remaining = snapshot_where(&hub, |snapshot| snapshot.len() == 1).await; + assert_eq!(remaining[0].nodes[0].path, "download"); + + assert_eq!( + mock.connections.load(Ordering::Relaxed), + 1, + "operation completion detaches only its import, not the open SSE stream" + ); + subscriber.shutdown().await; +} diff --git a/crates/workshop-server/src/gateway_progress/tests/recovery.rs b/crates/workshop-server/src/gateway_progress/tests/recovery.rs new file mode 100644 index 00000000..751c1936 --- /dev/null +++ b/crates/workshop-server/src/gateway_progress/tests/recovery.rs @@ -0,0 +1,38 @@ +use super::*; + +#[tokio::test] +async fn an_endpoint_replacement_moves_the_progress_subscription_immediately() { + let original = Arc::new(MockProgress::new()); + let original_url = spawn_gateway(Arc::clone(&original).router()).await; + let replacement = Arc::new(MockProgress::new()); + let replacement_url = spawn_gateway(Arc::clone(&replacement).router()).await; + let gateway = binding(&original_url); + let hub = Arc::new(ProgressHub::new()); + let subscriber = spawn(gateway.clone(), Arc::clone(&hub), GatewayHealth::new()); + + wait_for_connections(&original, 1).await; + gateway + .replace(&replacement_url, "") + .expect("the replacement publishes"); + wait_for_connections(&replacement, 1).await; + replacement.send(event_json( + "replacement-download", + &serde_json::json!({"Begun": {"weight": 1.0}}), + )); + let snapshot = snapshot_where(&hub, |snapshot| { + snapshot.iter().any(|operation| { + operation + .nodes + .iter() + .any(|node| node.path == "replacement-download") + }) + }) + .await; + assert_eq!(snapshot.len(), 1, "only the replacement import remains"); + assert_eq!( + original.connections.load(Ordering::Relaxed), + 1, + "the old endpoint is never retried after publication" + ); + subscriber.shutdown().await; +} diff --git a/crates/workshop-server/src/heartbeat.rs b/crates/workshop-server/src/heartbeat.rs index 6e44a2ad..eeee63ef 100644 --- a/crates/workshop-server/src/heartbeat.rs +++ b/crates/workshop-server/src/heartbeat.rs @@ -20,8 +20,9 @@ //! the Model menu's reachability (so `chat_ready` flips with the //! gateway), and a transition to reachable (boot's first probe included) //! refreshes the gateway's profile state and model catalog into their -//! buses and then restores a model selection when none is applied, so a -//! fresh boot lands ready to chat without a manual pick. +//! buses. If simultaneous startup leaves either source empty, later healthy +//! ticks retry each source independently until the profile and a selectable +//! model are both ready, then restore the selection exactly once. //! //! The task stops through its [`Heartbeat`] handle: the signal wins the //! loop's selects, so shutdown never waits out a tick or an in-flight @@ -32,10 +33,15 @@ use std::time::Duration; use tokio::sync::{oneshot, watch}; use crate::backoff::ReconnectBackoff; +#[cfg(test)] use crate::gateway::GatewayClient; +use crate::gateway_binding::{GatewayBinding, GatewaySnapshot}; use crate::protocol::{Activity, Severity, StatusBarUpdate}; use crate::push::Push; +mod refresh; +pub(crate) use refresh::{refresh_catalog, refresh_profiles}; + /// The status line announcing that the gateway answers its health probe. pub(crate) const CONNECTED_LABEL: &str = "Connected to gateway"; /// The status line announcing that the gateway does not answer. @@ -154,13 +160,15 @@ impl Heartbeat { /// menu behind `push`, which recomputes `chat_ready` from it. A /// transition to reachable - boot's first probe included - refreshes the /// gateway's profile state and model catalog through the same handle, -/// then restores a model selection when none is applied. The first probe -/// runs immediately; later probes follow `interval` while the gateway -/// answers and draw from `backoff` while it does not, ending the loop -/// when the backoff's budget exhausts. +/// then restores a model selection when none is applied. Healthy ticks +/// repeat each incomplete refresh independently, covering a gateway whose +/// health endpoint becomes ready before its catalog or profile state. The first +/// probe runs immediately; later probes follow `interval` while the +/// gateway answers and draw from `backoff` while it does not, ending the +/// loop when the backoff's budget exhausts. #[must_use] -pub fn spawn( - client: GatewayClient, +pub(crate) fn spawn( + gateway: GatewayBinding, push: Push, health: GatewayHealth, interval: Duration, @@ -168,7 +176,7 @@ pub fn spawn( ) -> Heartbeat { let (stop, mut stopped) = oneshot::channel(); let task = tokio::spawn(async move { - run(&client, &push, &health, interval, &backoff, &mut stopped).await; + run(&gateway, &push, &health, interval, &backoff, &mut stopped).await; }); Heartbeat { stop: Some(stop), @@ -185,8 +193,15 @@ pub fn spawn( /// successful probe deliberately never resets the backoff - only useful /// work does, elsewhere - and an exhausted budget ends the loop with a /// give-up report. +#[derive(Default)] +struct RefreshState { + profiles_ready: bool, + catalog_ready: bool, + selection_restored: bool, +} + async fn run( - client: &GatewayClient, + gateway: &GatewayBinding, push: &Push, health: &GatewayHealth, interval: Duration, @@ -194,6 +209,8 @@ async fn run( stop: &mut oneshot::Receiver<()>, ) { let mut last: Option = None; + let mut refresh = RefreshState::default(); + let mut gateway_changed = gateway.subscribe(); loop { // The first probe runs immediately; every later one waits here. if let Some(reachable) = last { @@ -211,164 +228,115 @@ async fn run( }; tokio::select! { _ = &mut *stop => break, + changed = gateway_changed.changed() => { + if changed.is_err() { + break; + } + last = None; + refresh = RefreshState::default(); + continue; + } () = tokio::time::sleep(wait) => {} } } + let snapshot = gateway.snapshot(); + let generation = snapshot.generation(); let reachable = tokio::select! { _ = &mut *stop => break, - reachable = client.health() => reachable, + changed = gateway_changed.changed() => { + if changed.is_err() { + break; + } + last = None; + refresh = RefreshState::default(); + continue; + } + reachable = snapshot.client().health() => reachable, }; - health.publish(reachable); - if last == Some(reachable) { + if gateway.generation() != generation { + last = None; + refresh = RefreshState::default(); continue; } + health.publish(reachable); + let transitioned = last != Some(reachable); last = Some(reachable); - // The menu recomputes chat_ready from reachability, so the - // verdict feeds it before any slower refresh work below. - push.menu().set_gateway_reachable(reachable); - if reachable { - push.push_status_update( - CONNECTED_LABEL, - "the gateway answers its health probe", - Activity::General, - ); + if transitioned { + // The menu recomputes chat_ready from reachability, so the + // verdict feeds it before any slower refresh work below. + push.menu().set_gateway_reachable(reachable); + if reachable { + push.push_status_update( + CONNECTED_LABEL, + "the gateway answers its health probe", + Activity::General, + ); + } else { + push.push_status_update( + UNREACHABLE_LABEL, + UNREACHABLE_DESCRIPTION, + Activity::General, + ); + } + } + if !reachable { + refresh = RefreshState::default(); + continue; + } + if !refresh.profiles_ready || !refresh.catalog_ready { // All menu state is server-owned and reaches the UI via // socket pushes - the UI fetches nothing on boot - so every // transition into reachable, boot's first probe included, - // (re)populates the profile state and the model catalog. A - // gateway that was down and answers again may also serve a - // different catalog than before the outage. The refreshes - // are independent fetches, joined as the profile-switch - // task joins them. + // (re)populates the profile state and the model catalog. + // Healthy ticks independently repeat either refresh until both + // sources are populated, because health and one ready source do + // not imply the other source is ready. The interval above bounds + // retries and keeps this from becoming a busy loop. tokio::select! { _ = &mut *stop => break, - () = async { - tokio::join!(refresh_profiles(client, push), refresh_catalog(client, push)); - } => {} + changed = gateway_changed.changed() => { + if changed.is_err() { + break; + } + last = None; + refresh = RefreshState::default(); + continue; + } + () = refresh_incomplete_sources( + &snapshot, + push, + &mut refresh, + ) => {} } + } + if refresh.profiles_ready && refresh.catalog_ready && !refresh.selection_restored { // A fresh boot has no selection, so restore the remembered // model for the now-known active profile (else the first // catalog model); a reconnect whose selection survived the - // outage is a no-op. + // outage is a no-op. This branch runs exactly once per reachable + // convergence because both readiness facts remain true. push.menu().restore_selection(); - } else { - push.push_status_update( - UNREACHABLE_LABEL, - UNREACHABLE_DESCRIPTION, - Activity::General, - ); - } - } -} - -/// Re-fetches the gateway's model catalog and pushes it to every session. -/// -/// A failed, declined, or malformed catalog is logged and skipped rather -/// than pushed: pushing a bad snapshot would clear pickers that still hold -/// a usable list. Runs on every transition into reachable (boot and -/// reconnect) and is shared with the profile-switch task in -/// [`crate::session::menu`], which refetches after a switch settles. -pub(crate) async fn refresh_catalog(client: &GatewayClient, push: &Push) { - let response = match client.list_models().await { - Ok(response) => response, - Err(error) => { - tracing::warn!(%error, "catalog refresh failed"); - return; - } - }; - if !response.status.is_success() { - tracing::warn!(status = %response.status, "catalog refresh was declined"); - return; - } - let body: serde_json::Value = match serde_json::from_slice(&response.body) { - Ok(body) => body, - Err(error) => { - tracing::warn!(%error, "catalog refresh was not JSON"); - return; - } - }; - let Some(models) = body.get("data").and_then(serde_json::Value::as_array) else { - tracing::warn!("catalog refresh carried no data array"); - return; - }; - push.push_models_catalog(models.clone()); -} - -/// The decoded body of `GET /admin/profiles`. -#[derive(serde::Deserialize)] -struct ProfileList { - /// Every profile name the gateway can serve, in gateway order. - profiles: Vec, -} - -/// The decoded body of `GET /admin/status`, reduced to the one field the -/// menu needs. -#[derive(serde::Deserialize)] -struct ProfileStatus { - /// The profile the gateway is serving. - #[serde(default)] - profile: Option, -} - -/// Fetches the gateway's profile list and active profile and publishes -/// them into the workbench snapshot. -/// -/// A gateway without profile support is a state, not an error: a failed, -/// declined, or malformed answer degrades that half to empty (logged by -/// its fetcher), so the menu shows no profiles rather than stale names. -/// Shared with the profile-switch task in [`crate::session::menu`], which -/// refetches after a switch settles. -pub(crate) async fn refresh_profiles(client: &GatewayClient, push: &Push) { - let (profiles, active) = tokio::join!(fetch_profile_list(client), fetch_active_profile(client)); - push.menu() - .set_profiles(profiles.unwrap_or_default(), active); -} - -/// The gateway's profile names from `GET /admin/profiles`, or `None` -/// when the request fails, is declined, or answers malformed JSON - each -/// logged and tolerated. -async fn fetch_profile_list(client: &GatewayClient) -> Option> { - let response = match client.list_profiles().await { - Ok(response) => response, - Err(error) => { - tracing::warn!(%error, "profile list fetch failed"); - return None; - } - }; - if !response.status.is_success() { - tracing::warn!(status = %response.status, "profile list was declined"); - return None; - } - match serde_json::from_slice::(&response.body) { - Ok(list) => Some(list.profiles), - Err(error) => { - tracing::warn!(%error, "profile list was not the expected JSON"); - None + refresh.selection_restored = true; } } } -/// The active profile name from `GET /admin/status`, or `None` when the -/// request fails, is declined, or answers malformed JSON - each logged -/// and tolerated. -async fn fetch_active_profile(client: &GatewayClient) -> Option { - let response = match client.profile_status().await { - Ok(response) => response, - Err(error) => { - tracing::warn!(%error, "profile status fetch failed"); - return None; - } - }; - if !response.status.is_success() { - tracing::warn!(status = %response.status, "profile status was declined"); - return None; - } - match serde_json::from_slice::(&response.body) { - Ok(status) => status.profile, - Err(error) => { - tracing::warn!(%error, "profile status was not the expected JSON"); - None +/// Refreshes only the Gateway-owned menu sources that have not converged. +async fn refresh_incomplete_sources( + snapshot: &GatewaySnapshot, + push: &Push, + refresh: &mut RefreshState, +) { + match (refresh.profiles_ready, refresh.catalog_ready) { + (false, false) => { + (refresh.profiles_ready, refresh.catalog_ready) = tokio::join!( + refresh_profiles(snapshot.client(), push), + refresh_catalog(snapshot.client(), push) + ); } + (false, true) => refresh.profiles_ready = refresh_profiles(snapshot.client(), push).await, + (true, false) => refresh.catalog_ready = refresh_catalog(snapshot.client(), push).await, + (true, true) => {} } } @@ -377,7 +345,7 @@ mod tests { use super::*; use std::sync::Arc; - use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use axum::Router; use axum::extract::State; @@ -541,12 +509,12 @@ mod tests { status: &StatusBus, catalog: &CatalogBus, ) -> (Heartbeat, GatewayHealth, MenuBus, ReconnectBackoff) { - let client = GatewayClient::new(base_url, "").expect("client builds in tests"); + let gateway = GatewayBinding::new(base_url, "").expect("binding builds in tests"); let health = GatewayHealth::new(); let menu = MenuBus::new(catalog.clone(), None); let backoff = test_backoff(); let heartbeat = spawn( - client, + gateway, Push::new(status.clone(), catalog.clone(), menu.clone()), health.clone(), TEST_INTERVAL, @@ -626,7 +594,7 @@ mod tests { let menu = MenuBus::new(catalog.clone(), None); let mut rx = status.subscribe(); let heartbeat = spawn( - client, + GatewayBinding::from_client(client), Push::new(status.clone(), catalog, menu), GatewayHealth::new(), TEST_INTERVAL, @@ -722,7 +690,7 @@ mod tests { .as_array() .expect("the fixture is an array") .clone(), - "the push carries the gateway's data array verbatim" + "the push carries every chat-capable gateway model" ); heartbeat.shutdown().await; } @@ -759,57 +727,6 @@ mod tests { heartbeat.shutdown().await; } - #[tokio::test] - async fn the_initial_connect_pushes_the_catalog_and_readies_chat() { - // Boot populate: all state reaches the UI via socket pushes, so - // the first reachable probe fetches the catalog and restores a - // model selection - a workshop booted against a live gateway is - // ready to chat with no user interaction. - let healthy = Arc::new(AtomicBool::new(true)); - let base_url = spawn_gateway(Arc::clone(&healthy)).await; - let status = StatusBus::new(); - let catalog = CatalogBus::new(); - let mut catalog_rx = catalog.subscribe(); - let (heartbeat, _health, menu, _backoff) = heartbeat_on(&base_url, &status, &catalog); - - let push: CatalogPush = tokio::time::timeout(Duration::from_secs(5), catalog_rx.recv()) - .await - .expect("the boot catalog arrives within the deadline") - .expect("the catalog bus is open"); - assert_eq!( - push.models, - serde_json::json!([{"id": "test-model", "object": "model", "owned_by": "promptforge"}]) - .as_array() - .expect("the fixture is an array") - .clone(), - "the push carries the gateway's data array verbatim" - ); - let ready = snapshot_where(&menu, |snapshot| snapshot.chat_ready).await; - assert_eq!( - ready.selected_model.as_deref(), - Some("test-model"), - "boot restores a selection without any user interaction" - ); - heartbeat.shutdown().await; - } - - #[tokio::test] - async fn the_initial_connect_populates_the_profile_state() { - // Boot populate: the first reachable probe fetches the profile - // endpoints, so a workshop started against a live gateway shows - // its profiles without waiting for an outage cycle. - let healthy = Arc::new(AtomicBool::new(true)); - let base_url = spawn_gateway(Arc::clone(&healthy)).await; - let status = StatusBus::new(); - let catalog = CatalogBus::new(); - let (heartbeat, _health, menu, _backoff) = heartbeat_on(&base_url, &status, &catalog); - - let populated = snapshot_where(&menu, |snapshot| !snapshot.profiles.is_empty()).await; - assert_eq!(populated.profiles, ["coding", "main"]); - assert_eq!(populated.active.as_deref(), Some("main")); - heartbeat.shutdown().await; - } - #[tokio::test] async fn a_down_to_up_transition_publishes_a_populated_snapshot() { let healthy = Arc::new(AtomicBool::new(false)); @@ -922,7 +839,7 @@ mod tests { let status = StatusBus::new(); let catalog = CatalogBus::new(); let mut rx = status.subscribe(); - let client = GatewayClient::new(&base_url, "").expect("client builds in tests"); + let gateway = GatewayBinding::new(&base_url, "").expect("binding builds in tests"); let health = GatewayHealth::new(); let menu = MenuBus::new(catalog.clone(), None); // A budget of a few schedule steps: exhausted within a handful of @@ -933,7 +850,7 @@ mod tests { Duration::from_millis(50), ); let heartbeat = spawn( - client, + gateway, Push::new(status.clone(), catalog, menu), health.clone(), TEST_INTERVAL, @@ -960,11 +877,12 @@ mod tests { // A long interval: if the stop signal did not win the select, the // shutdown would block for the whole minute. let status = StatusBus::new(); - let client = GatewayClient::new("http://127.0.0.1:1", "").expect("client builds in tests"); + let gateway = + GatewayBinding::new("http://127.0.0.1:1", "").expect("binding builds in tests"); let catalog = CatalogBus::new(); let menu = crate::menu::MenuBus::new(catalog.clone(), None); let heartbeat = spawn( - client, + gateway, Push::new(status, catalog, menu), GatewayHealth::new(), Duration::from_secs(60), @@ -974,4 +892,7 @@ mod tests { .await .expect("shutdown does not wait out the interval"); } + + mod recovery; + mod startup_convergence; } diff --git a/crates/workshop-server/src/heartbeat/refresh.rs b/crates/workshop-server/src/heartbeat/refresh.rs new file mode 100644 index 00000000..0bb3c82b --- /dev/null +++ b/crates/workshop-server/src/heartbeat/refresh.rs @@ -0,0 +1,117 @@ +//! Gateway profile and model-catalog refresh after reachability. + +use crate::catalog::is_chat_capable; +use crate::gateway::GatewayClient; +use crate::push::Push; + +/// Re-fetches the gateway's model catalog and pushes it to every session. +/// +/// A failed, declined, or malformed catalog is logged and skipped rather +/// than pushed: pushing a bad snapshot would clear pickers that still hold +/// a usable list. +pub(crate) async fn refresh_catalog(client: &GatewayClient, push: &Push) -> bool { + let response = match client.list_models().await { + Ok(response) => response, + Err(error) => { + tracing::warn!(%error, "catalog refresh failed"); + return false; + } + }; + if !response.status.is_success() { + tracing::warn!(status = %response.status, "catalog refresh was declined"); + return false; + } + let body: serde_json::Value = match serde_json::from_slice(&response.body) { + Ok(body) => body, + Err(error) => { + tracing::warn!(%error, "catalog refresh was not JSON"); + return false; + } + }; + let Some(models) = body.get("data").and_then(serde_json::Value::as_array) else { + tracing::warn!("catalog refresh carried no data array"); + return false; + }; + let selectable = models.iter().any(is_chat_capable); + push.push_models_catalog(models.clone()); + selectable +} + +/// The decoded body of `GET /admin/profiles`. +#[derive(serde::Deserialize)] +struct ProfileList { + /// Every profile name the gateway can serve, in gateway order. + profiles: Vec, +} + +/// The decoded body of `GET /admin/status`, reduced to the one field the +/// menu needs. +#[derive(serde::Deserialize)] +struct ProfileStatus { + /// The profile the gateway is serving. + #[serde(default)] + profile: Option, +} + +/// Fetches the gateway's profile list and active profile and publishes +/// them into the workbench snapshot. +/// +/// A gateway without profile support is a state, not an error: a failed, +/// declined, or malformed answer degrades that half to empty, so the menu +/// shows no profiles rather than stale names. +pub(crate) async fn refresh_profiles(client: &GatewayClient, push: &Push) -> bool { + let (profiles, active) = tokio::join!(fetch_profile_list(client), fetch_active_profile(client)); + let ready = profiles.as_ref().is_some_and(|profiles| { + !profiles.is_empty() + && active + .as_ref() + .is_some_and(|active| profiles.contains(active)) + }); + push.menu() + .set_profiles(profiles.unwrap_or_default(), active); + ready +} + +/// The gateway's profile names, or `None` on a failed response. +async fn fetch_profile_list(client: &GatewayClient) -> Option> { + let response = match client.list_profiles().await { + Ok(response) => response, + Err(error) => { + tracing::warn!(%error, "profile list fetch failed"); + return None; + } + }; + if !response.status.is_success() { + tracing::warn!(status = %response.status, "profile list was declined"); + return None; + } + match serde_json::from_slice::(&response.body) { + Ok(list) => Some(list.profiles), + Err(error) => { + tracing::warn!(%error, "profile list was not the expected JSON"); + None + } + } +} + +/// The active profile name, or `None` on a failed response. +async fn fetch_active_profile(client: &GatewayClient) -> Option { + let response = match client.profile_status().await { + Ok(response) => response, + Err(error) => { + tracing::warn!(%error, "profile status fetch failed"); + return None; + } + }; + if !response.status.is_success() { + tracing::warn!(status = %response.status, "profile status was declined"); + return None; + } + match serde_json::from_slice::(&response.body) { + Ok(status) => status.profile, + Err(error) => { + tracing::warn!(%error, "profile status was not the expected JSON"); + None + } + } +} diff --git a/crates/workshop-server/src/heartbeat/tests/recovery.rs b/crates/workshop-server/src/heartbeat/tests/recovery.rs new file mode 100644 index 00000000..32692413 --- /dev/null +++ b/crates/workshop-server/src/heartbeat/tests/recovery.rs @@ -0,0 +1,59 @@ +use super::*; + +/// Catalog route that accepts only the replacement sidecar bearer. +async fn replacement_models(headers: axum::http::HeaderMap) -> Response { + if headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + != Some("Bearer replacement-key") + { + return StatusCode::UNAUTHORIZED.into_response(); + } + mock_models().await +} + +#[tokio::test] +async fn a_replaced_endpoint_wakes_the_heartbeat_and_refreshes_with_its_new_key() { + let replacement = serve( + Router::new() + .route("/health", get(|| async { StatusCode::OK })) + .route("/v1/models", get(replacement_models)) + .route("/admin/profiles", get(mock_profiles)) + .route("/admin/status", get(mock_profile_status)), + ) + .await; + let gateway = GatewayBinding::new("http://127.0.0.1:1", "old-key").expect("binding builds"); + let status = StatusBus::new(); + let catalog = CatalogBus::new(); + let menu = MenuBus::new(catalog.clone(), None); + let health = GatewayHealth::new(); + let mut status_rx = status.subscribe(); + let mut catalog_rx = catalog.subscribe(); + let heartbeat = spawn( + gateway.clone(), + Push::new(status, catalog, menu), + health.clone(), + Duration::from_secs(60), + test_backoff(), + ); + + assert_eq!( + next_update(&mut status_rx).await.label, + "Gateway unreachable" + ); + gateway + .replace(&replacement, "replacement-key") + .expect("the replacement publishes"); + assert_eq!( + next_update(&mut status_rx).await.label, + "Connected to gateway", + "publication wakes the heartbeat without waiting out its backoff" + ); + let models = tokio::time::timeout(Duration::from_secs(5), catalog_rx.recv()) + .await + .expect("the replacement catalog arrives") + .expect("the catalog channel stays open"); + assert_eq!(models.models[0]["id"], "test-model"); + assert!(health.is_reachable()); + heartbeat.shutdown().await; +} diff --git a/crates/workshop-server/src/heartbeat/tests/startup_convergence.rs b/crates/workshop-server/src/heartbeat/tests/startup_convergence.rs new file mode 100644 index 00000000..f321f946 --- /dev/null +++ b/crates/workshop-server/src/heartbeat/tests/startup_convergence.rs @@ -0,0 +1,213 @@ +use super::*; + +/// Startup state whose health is continuously true while its catalog +/// becomes ready later. +struct DelayedCatalog { + ready: AtomicBool, + requests: AtomicUsize, +} + +/// Startup state whose catalog is ready before both profile endpoints. +struct DelayedProfiles { + ready: AtomicBool, + list_requests: AtomicUsize, + status_requests: AtomicUsize, +} + +/// A catalog that is empty until the test publishes readiness. +async fn delayed_models(State(state): State>) -> Response { + state.requests.fetch_add(1, Ordering::Relaxed); + let body = if state.ready.load(Ordering::Relaxed) { + CATALOG + } else { + r#"{"object":"list","data":[]}"# + }; + ( + [(axum::http::header::CONTENT_TYPE, "application/json")], + body, + ) + .into_response() +} + +async fn delayed_profiles(State(state): State>) -> Response { + state.list_requests.fetch_add(1, Ordering::Relaxed); + let body = if state.ready.load(Ordering::Relaxed) { + r#"{"profiles":["coding","main"]}"# + } else { + r#"{"profiles":[]}"# + }; + ( + [(axum::http::header::CONTENT_TYPE, "application/json")], + body, + ) + .into_response() +} + +async fn delayed_profile_status(State(state): State>) -> Response { + state.status_requests.fetch_add(1, Ordering::Relaxed); + let body = if state.ready.load(Ordering::Relaxed) { + r#"{"profile":"main","models":["test-model"]}"# + } else { + r#"{"profile":null,"models":[]}"# + }; + ( + [(axum::http::header::CONTENT_TYPE, "application/json")], + body, + ) + .into_response() +} + +#[tokio::test] +async fn the_initial_connect_populates_the_profile_state() { + let healthy = Arc::new(AtomicBool::new(true)); + let base_url = spawn_gateway(Arc::clone(&healthy)).await; + let status = StatusBus::new(); + let catalog = CatalogBus::new(); + let (heartbeat, _health, menu, _backoff) = heartbeat_on(&base_url, &status, &catalog); + + let populated = snapshot_where(&menu, |snapshot| !snapshot.profiles.is_empty()).await; + assert_eq!(populated.profiles, ["coding", "main"]); + assert_eq!(populated.active.as_deref(), Some("main")); + heartbeat.shutdown().await; +} + +#[tokio::test] +async fn the_initial_connect_pushes_the_catalog_and_readies_chat() { + let healthy = Arc::new(AtomicBool::new(true)); + let base_url = spawn_gateway(Arc::clone(&healthy)).await; + let status = StatusBus::new(); + let catalog = CatalogBus::new(); + let mut catalog_rx = catalog.subscribe(); + let (heartbeat, _health, menu, _backoff) = heartbeat_on(&base_url, &status, &catalog); + + let push: CatalogPush = tokio::time::timeout(Duration::from_secs(5), catalog_rx.recv()) + .await + .expect("the boot catalog arrives within the deadline") + .expect("the catalog bus is open"); + assert_eq!( + push.models, + serde_json::json!([{"id": "test-model", "object": "model", "owned_by": "promptforge"}]) + .as_array() + .expect("the fixture is an array") + .clone(), + "the push carries every chat-capable gateway model" + ); + let ready = snapshot_where(&menu, |snapshot| snapshot.chat_ready).await; + assert_eq!( + ready.selected_model.as_deref(), + Some("test-model"), + "boot restores a selection without any user interaction" + ); + heartbeat.shutdown().await; +} + +#[tokio::test] +async fn a_healthy_gateway_retries_refresh_until_its_catalog_is_ready() { + let state = Arc::new(DelayedCatalog { + ready: AtomicBool::new(false), + requests: AtomicUsize::new(0), + }); + let base_url = serve( + Router::new() + .route("/health", get(|| async { StatusCode::OK })) + .route("/v1/models", get(delayed_models)) + .route("/admin/profiles", get(mock_profiles)) + .route("/admin/status", get(mock_profile_status)) + .with_state(Arc::clone(&state)), + ) + .await; + let status = StatusBus::new(); + let catalog = CatalogBus::new(); + let (heartbeat, health, menu, _backoff) = heartbeat_on(&base_url, &status, &catalog); + + tokio::time::timeout(Duration::from_secs(5), async { + while state.requests.load(Ordering::Relaxed) < 1 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("the first empty refresh completes"); + assert!(health.is_reachable(), "health stays continuously true"); + assert!( + menu.latest() + .is_some_and(|snapshot| snapshot.selected_model.is_none()), + "an empty first catalog cannot restore a selection" + ); + + state.ready.store(true, Ordering::Relaxed); + let ready = snapshot_where(&menu, |snapshot| snapshot.chat_ready).await; + assert_eq!(ready.selected_model.as_deref(), Some("test-model")); + assert!( + state.requests.load(Ordering::Relaxed) >= 2, + "readiness changed without a health transition, so refresh had to retry" + ); + + let requests_after_restore = state.requests.load(Ordering::Relaxed); + tokio::time::sleep(TEST_INTERVAL * 4).await; + assert_eq!( + state.requests.load(Ordering::Relaxed), + requests_after_restore, + "selection restoration ends refresh retries" + ); + heartbeat.shutdown().await; +} + +#[tokio::test] +async fn a_healthy_gateway_waits_for_profiles_after_its_catalog_is_ready() { + let state = Arc::new(DelayedProfiles { + ready: AtomicBool::new(false), + list_requests: AtomicUsize::new(0), + status_requests: AtomicUsize::new(0), + }); + let base_url = serve( + Router::new() + .route("/health", get(|| async { StatusCode::OK })) + .route("/v1/models", get(mock_models)) + .route("/admin/profiles", get(delayed_profiles)) + .route("/admin/status", get(delayed_profile_status)) + .with_state(Arc::clone(&state)), + ) + .await; + let status = StatusBus::new(); + let catalog = CatalogBus::new(); + let (heartbeat, health, menu, _backoff) = heartbeat_on(&base_url, &status, &catalog); + + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let snapshot = menu.latest(); + if state.list_requests.load(Ordering::Relaxed) >= 1 + && state.status_requests.load(Ordering::Relaxed) >= 1 + && catalog + .latest() + .is_some_and(|catalog| !catalog.models.is_empty()) + && snapshot.is_some_and(|snapshot| snapshot.profiles.is_empty()) + { + break; + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("the first empty profile refresh completes"); + assert!(health.is_reachable(), "health stays continuously true"); + assert_eq!( + menu.latest().and_then(|snapshot| snapshot.selected_model), + None, + "catalog readiness alone cannot end startup convergence" + ); + + state.ready.store(true, Ordering::Relaxed); + let ready = snapshot_where(&menu, |snapshot| { + snapshot.profiles == ["coding", "main"] + && snapshot.active.as_deref() == Some("main") + && snapshot.chat_ready + }) + .await; + assert_eq!(ready.selected_model.as_deref(), Some("test-model")); + assert!( + state.list_requests.load(Ordering::Relaxed) >= 2 + && state.status_requests.load(Ordering::Relaxed) >= 2, + "profile readiness changed without a health transition, so both endpoints had to retry" + ); + heartbeat.shutdown().await; +} diff --git a/crates/workshop-server/src/input.rs b/crates/workshop-server/src/input.rs index 93c9d658..690aa7ca 100644 --- a/crates/workshop-server/src/input.rs +++ b/crates/workshop-server/src/input.rs @@ -263,8 +263,29 @@ pub fn deliver_input_response( execution: &str, section: &str, response: InputResponse, +) -> Result<(), WaitError> { + deliver_input_response_before_completion( + observer, + registry, + execution, + section, + response, + || {}, + ) +} + +/// Delivers one response with a synchronous seam after the durable input +/// observation and before the suspended tool call resumes. +pub(crate) fn deliver_input_response_before_completion( + observer: &dyn Observer, + registry: &WaitRegistry, + execution: &str, + section: &str, + response: InputResponse, + before_completion: impl FnOnce(), ) -> Result<(), WaitError> { observer.on_user_input(execution, section, &response.text); + before_completion(); registry.complete(&response.token, response.text) } @@ -444,13 +465,11 @@ mod tests { use promptforge_core_support::observe::Observation; use promptforge_tools::OutputTrust; - /// Hostile operator text - CRLF, quotes, JSON braces, a backslash, - /// and a multi-byte scalar - so byte-exactness is proven on the bytes - /// most likely to be mangled by an envelope or a codec. + /// Hostile operator text covering the bytes most likely to be mangled + /// by an envelope or codec. const GNARLY: &str = "line1\r\nline2 \"quoted\" {\"text\":\"decoy\"} \\slash \u{1F980}"; - /// A tool over a fresh registry and channel; the channel's initial - /// receiver is dropped, so tests start with zero subscribers. + /// A fresh tool, registry, and channel with no subscribers. fn tool_fixture() -> ( UserInputTool, Arc, @@ -462,7 +481,6 @@ mod tests { (tool, registry, frames) } - /// Waits for a spawned call to register its wait, without a socket. async fn registered_token(registry: &WaitRegistry) -> String { for _ in 0..1024 { if let Some(token) = registry.unresolved().first().cloned() { @@ -473,7 +491,6 @@ mod tests { panic!("the tool call never registered its wait"); } - /// Receives the next frame and unwraps the `input_required` token. async fn required_token(socket: &mut broadcast::Receiver) -> String { let frame = socket.recv().await.expect("a frame arrives"); let InputFrame::Required { token } = frame else { @@ -733,19 +750,22 @@ mod tests { ); } - /// Records every `on_user_input` report for the producer tests. #[derive(Default)] struct RecordingObserver { inputs: Mutex>, } + impl RecordingObserver { + fn inputs(&self) -> MutexGuard<'_, Vec<(String, String, String)>> { + self.inputs.lock().expect("the recorder mutex stays usable") + } + } + impl Observer for RecordingObserver { fn observe(&self, _execution: &str, _section: &str, _event: Observation) {} fn on_user_input(&self, execution: &str, section: &str, text: &str) { - self.inputs - .lock() - .expect("the recorder mutex stays usable") + self.inputs() .push((execution.to_owned(), section.to_owned(), text.to_owned())); } } @@ -772,11 +792,7 @@ mod tests { "the completed value is the response text byte-exact" ); assert_eq!( - observer - .inputs - .lock() - .expect("the recorder mutex stays usable") - .as_slice(), + observer.inputs().as_slice(), &[("run-1".to_owned(), "chat".to_owned(), GNARLY.to_owned())], "exactly one byte-exact event per response" ); @@ -796,11 +812,7 @@ mod tests { Err(WaitError::UnknownToken) ); assert_eq!( - observer - .inputs - .lock() - .expect("the recorder mutex stays usable") - .len(), + observer.inputs().len(), 2, "the event fires exactly once per response, even a stale one" ); diff --git a/crates/workshop-server/src/lib.rs b/crates/workshop-server/src/lib.rs index 1cb3370a..a9b71bc6 100644 --- a/crates/workshop-server/src/lib.rs +++ b/crates/workshop-server/src/lib.rs @@ -19,6 +19,7 @@ mod csp; mod deadline; mod error; mod gateway; +mod gateway_binding; mod gateway_progress; mod heartbeat; mod input; @@ -49,7 +50,7 @@ pub mod fixtures { pub use crate::app::state_with_gateway; pub use crate::backoff::ReconnectBackoff; pub use crate::catalog::CatalogBus; - pub use crate::heartbeat::{GatewayHealth, Heartbeat, spawn as spawn_heartbeat}; + pub use crate::heartbeat::{GatewayHealth, Heartbeat}; pub use crate::menu::{MenuBus, MenuRefusal}; pub use crate::protocol::{Activity, Progress, Severity, StatusBarUpdate}; pub use crate::push::Push; @@ -57,6 +58,37 @@ pub mod fixtures { #[cfg(feature = "test-fixtures")] pub use crate::app::fixtures::spawn_gateway; + + /// Returns the host-only Gateway publisher from fixture state. + #[cfg(feature = "test-fixtures")] + #[must_use] + pub fn gateway_updater(state: &crate::AppState) -> crate::GatewayUpdater { + state.gateway_updater() + } + + /// Starts a heartbeat around a fixture Gateway client. + #[must_use] + pub fn spawn_heartbeat( + client: crate::GatewayClient, + push: crate::Push, + health: GatewayHealth, + interval: std::time::Duration, + backoff: ReconnectBackoff, + ) -> Heartbeat { + crate::heartbeat::spawn( + crate::gateway_binding::GatewayBinding::from_client(client), + push, + health, + interval, + backoff, + ) + } + + /// Spawns a Workshop test server against the explicit configured Gateway. + #[cfg(feature = "test-fixtures")] + pub fn spawn(config: crate::Config) -> Result { + crate::serve::spawn_resolved(config) + } } pub use app::{AppState, DEFAULT_ADDR, StateError, router}; @@ -68,10 +100,11 @@ pub use gateway::{ CacheEvent, CacheResponse, GatewayClient, GatewayError, GatewayResponse, SsePayloadStream, SwitchEvent, SwitchEventStream, SwitchResponse, switch_events, }; +pub use gateway_binding::GatewayUpdater; pub use input::{UserInputTool, WaitError, WaitRegistry, deliver_input_response}; pub use observer::WorkshopObserver; pub use protocol::{Activity, InputFrame, InputResponse}; pub use push::Push; pub use resolve::{GatewaySource, ResolveError, ResolvedGateway}; -pub use serve::{ServerHandle, SpawnError, Termination, spawn, spawn_with_routes}; +pub use serve::{ServerHandle, SpawnError, Termination, spawn}; pub use session_agents::AgentSessions; diff --git a/crates/workshop-server/src/menu.rs b/crates/workshop-server/src/menu.rs index bd84c4f9..498f07a2 100644 --- a/crates/workshop-server/src/menu.rs +++ b/crates/workshop-server/src/menu.rs @@ -3,7 +3,7 @@ //! bus, and the per-profile model memory persisted in the state directory. //! //! The server owns all Model-menu state and the UI only renders it; in -//! particular `chat_ready` is computed here - catalog non-empty, a model +//! particular `chat_ready` is computed here - a chat-capable model //! selected, no switch in flight, gateway reachable - and never derived //! client-side. Like the catalog bus, the channel is a tokio broadcast: //! publishing never blocks, a publish with no sessions is a no-op, and a @@ -25,7 +25,7 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use tokio::sync::broadcast; -use crate::catalog::CatalogBus; +use crate::catalog::{CatalogBus, is_chat_capable}; use crate::protocol::WorkbenchSnapshot; /// Ring capacity of the menu bus. Pushes follow user interactions and @@ -325,6 +325,13 @@ impl MenuBus { } } + /// Revalidates the selection after an integration fixture publishes + /// directly to the catalog bus. + #[cfg(feature = "test-fixtures")] + pub fn reconcile_catalog_for_test(&self) { + self.reconcile_catalog(); + } + /// The state guard, recovering a lock poisoned by a panicking peer /// rather than wedging the process (the crate's zone-two policy). fn lock_state(&self) -> MutexGuard<'_, MenuState> { @@ -334,16 +341,16 @@ impl MenuBus { /// Builds the wire snapshot of `state`, computing `chat_ready` from /// its four conditions. fn snapshot(&self, state: &MenuState) -> WorkbenchSnapshot { - let catalog_nonempty = self + let catalog_has_chat = self .catalog .latest() - .is_some_and(|push| !push.models.is_empty()); + .is_some_and(|push| push.models.iter().any(is_chat_capable)); WorkbenchSnapshot { profiles: state.profiles.clone(), active: state.active.clone(), switching: state.switching.clone(), selected_model: state.selected_model.clone(), - chat_ready: catalog_nonempty + chat_ready: catalog_has_chat && state.selected_model.is_some() && state.switching.is_none() && state.gateway_reachable, @@ -384,19 +391,22 @@ impl MenuBus { /// Whether the catalog `models` array holds an entry whose `id` is `id`. fn models_contain(models: &[serde_json::Value], id: &str) -> bool { - models - .iter() - .any(|model| model.get("id").and_then(serde_json::Value::as_str) == Some(id)) + models.iter().any(|model| { + is_chat_capable(model) && model.get("id").and_then(serde_json::Value::as_str) == Some(id) + }) } -/// The `id` of the first catalog entry carrying one, when any does. +/// The `id` of the first chat-capable catalog entry, when any does. fn first_model_id(models: &[serde_json::Value]) -> Option { - models.iter().find_map(|model| { - model - .get("id") - .and_then(serde_json::Value::as_str) - .map(str::to_string) - }) + models + .iter() + .filter(|model| is_chat_capable(model)) + .find_map(|model| { + model + .get("id") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + }) } /// The persisted shape of [`WORKSHOP_STATE_FILE`]. Server state only: diff --git a/crates/workshop-server/src/protocol.rs b/crates/workshop-server/src/protocol.rs index 4f6b462d..ff7b6eda 100644 --- a/crates/workshop-server/src/protocol.rs +++ b/crates/workshop-server/src/protocol.rs @@ -386,7 +386,7 @@ pub(crate) struct StatusFrame<'a> { /// One pushed model catalog. #[derive(Debug, Clone, PartialEq)] pub(crate) struct CatalogPush { - /// The gateway's `/v1/models` `data` array, verbatim. + /// The chat-capable subset of the gateway's model array. pub(crate) models: Vec, } @@ -414,7 +414,7 @@ pub(crate) struct CatalogFrame<'a> { /// One pushed workbench snapshot: the server-owned Model-menu state. /// -/// The server computes `chat_ready` - catalog non-empty, a model +/// The server computes `chat_ready` - a chat-capable model available, one /// selected, no switch in flight, gateway reachable - and the UI never /// derives it. #[derive(Debug, Clone, PartialEq)] diff --git a/crates/workshop-server/src/push.rs b/crates/workshop-server/src/push.rs index 4b7c48ee..586bd6be 100644 --- a/crates/workshop-server/src/push.rs +++ b/crates/workshop-server/src/push.rs @@ -97,10 +97,10 @@ impl Push { } /// Pushes one complete model catalog snapshot: a `{"type":"models",...}` - /// [`crate::protocol::CatalogFrame`] carrying the gateway's `data` - /// array verbatim. The single choke point for catalog publishes: the - /// menu revalidates its selection against the new catalog and - /// republishes the workbench snapshot when it changed. + /// [`crate::protocol::CatalogFrame`] carrying only chat-capable + /// entries. The single choke point for catalog publishes: the menu + /// revalidates its selection against the new catalog and republishes + /// the workbench snapshot when it changed. pub(crate) fn push_models_catalog(&self, models: Vec) { self.catalog.publish(models); self.menu.reconcile_catalog(); diff --git a/crates/workshop-server/src/relay.rs b/crates/workshop-server/src/relay.rs index 35ee3b9d..32ccd50f 100644 --- a/crates/workshop-server/src/relay.rs +++ b/crates/workshop-server/src/relay.rs @@ -25,7 +25,8 @@ pub(crate) async fn models(State(state): State) -> Response { "fetching the gateway model catalog", Activity::General, ); - let result = state.gateway.list_models().await; + let gateway = state.gateway_snapshot(); + let result = gateway.client().list_models().await; report_gateway_outcome(&push, &result, "GET /v1/models"); relay(result) } diff --git a/crates/workshop-server/src/resolve.rs b/crates/workshop-server/src/resolve.rs index 8df37363..1ccf5479 100644 --- a/crates/workshop-server/src/resolve.rs +++ b/crates/workshop-server/src/resolve.rs @@ -10,7 +10,7 @@ use std::path::Path; -use shared_sidecar::{Resolution, SidecarError, StaleReason}; +use shared_sidecar::{Resolution, SidecarError, StaleReason, ValidatedConnection}; use crate::config::GatewayConfig; use crate::protocol::Activity; @@ -22,6 +22,7 @@ use crate::push::Push; pub struct ResolvedGateway { base_url: String, api_key: String, + identity: Option, source: GatewaySource, stale: Option, } @@ -45,6 +46,7 @@ impl ResolvedGateway { Self { base_url: config.base_url.clone(), api_key: config.api_key.clone(), + identity: None, source: GatewaySource::Config, stale: None, } @@ -62,6 +64,11 @@ impl ResolvedGateway { &self.api_key } + /// The validated local Gateway boot, when discovery won. + pub(crate) fn identity(&self) -> Option<&ValidatedConnection> { + self.identity.as_ref() + } + /// Which source won the resolution. #[must_use] pub fn source(&self) -> GatewaySource { @@ -151,14 +158,18 @@ fn resolve_with( let mut stale = None; if let Some(run_dir) = run_dir { match probe(run_dir) { - Ok(Resolution::Attach(file)) => { - return Ok(ResolvedGateway { - base_url: format!("http://127.0.0.1:{}", file.port), - api_key: file.api_key, - source: GatewaySource::ConnectionFile, - stale: None, - }); - } + Ok(Resolution::Attach(file)) => match validate_resolved(file) { + Ok(identity) => { + return Ok(ResolvedGateway { + base_url: format!("http://127.0.0.1:{}", identity.port()), + api_key: identity.api_key().to_owned(), + identity: Some(identity), + source: GatewaySource::ConnectionFile, + stale: None, + }); + } + Err(reason) => stale = Some(reason), + }, Ok(Resolution::Stale(reason)) => { tracing::warn!( reason = stale_clause(reason), @@ -179,6 +190,7 @@ fn resolve_with( return Ok(ResolvedGateway { base_url: config.base_url.clone(), api_key: config.api_key.clone(), + identity: None, source: GatewaySource::Config, stale, }); @@ -186,6 +198,21 @@ fn resolve_with( Err(ResolveError::new(stale)) } +/// Reifies the shared resolver's live result as the capability stored in +/// Workshop's immutable Gateway snapshot. +fn validate_resolved( + file: shared_sidecar::ConnectionFile, +) -> Result { + #[cfg(test)] + { + ValidatedConnection::validate_for_test(file) + } + #[cfg(not(test))] + { + ValidatedConnection::validate(file) + } +} + /// Reports the resolution outcome where the house surfaces startup state: /// a condemned file's reason and the winning source on the status bus, /// the same facts in the log. @@ -299,19 +326,23 @@ mod tests { let port = listener.local_addr().expect("fixture address").port(); std::thread::spawn(move || { while let Ok((mut stream, _)) = listener.accept() { - let mut buffer = [0u8; 1024]; - let Ok(read) = stream.read(&mut buffer) else { - continue; - }; - let request = String::from_utf8_lossy(&buffer[..read]); - let response = if request.starts_with("GET /health ") - || request.contains(&format!("Authorization: Bearer {expected_key}\r\n")) - { - &b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"[..] - } else { - &b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"[..] - }; - let _ = stream.write_all(response); + for _ in 0..2 { + let mut buffer = [0u8; 1024]; + let Ok(read) = stream.read(&mut buffer) else { + break; + }; + let request = String::from_utf8_lossy(&buffer[..read]); + let accepted = request.starts_with("GET /health ") + || request.contains(&format!("Authorization: Bearer {expected_key}\r\n")); + let response = if accepted { + &b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"[..] + } else { + &b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"[..] + }; + if stream.write_all(response).is_err() { + break; + } + } } }); port @@ -338,6 +369,11 @@ mod tests { assert_eq!(resolved.source(), GatewaySource::ConnectionFile); assert_eq!(resolved.base_url(), format!("http://127.0.0.1:{port}")); assert_eq!(resolved.api_key(), "file-key"); + assert_eq!( + resolved.identity().map(ValidatedConnection::port), + Some(port), + "the winning sidecar retains its validated identity for the initial snapshot" + ); assert_eq!(resolved.stale(), None); } @@ -527,6 +563,7 @@ mod tests { let resolved = ResolvedGateway { base_url: "http://127.0.0.1:4000".to_owned(), api_key: "k".to_owned(), + identity: None, source: GatewaySource::Config, stale: Some(StaleReason::KeyRejected), }; diff --git a/crates/workshop-server/src/routes.rs b/crates/workshop-server/src/routes.rs index 990d30c1..9ff6a613 100644 --- a/crates/workshop-server/src/routes.rs +++ b/crates/workshop-server/src/routes.rs @@ -5,5 +5,5 @@ pub(crate) mod assets; pub(crate) mod chat; pub(crate) mod gateway_config; pub(crate) mod health; -pub(crate) mod stt; +pub(crate) mod realtime; pub(crate) mod workspace; diff --git a/crates/workshop-server/src/routes/assets.rs b/crates/workshop-server/src/routes/assets.rs index 21f18a93..3b593ada 100644 --- a/crates/workshop-server/src/routes/assets.rs +++ b/crates/workshop-server/src/routes/assets.rs @@ -59,98 +59,4 @@ async fn ui_program_icon_2x() -> Response { } #[cfg(test)] -mod tests { - use axum::body::Body; - use axum::http::{Request, StatusCode, header}; - use tower::ServiceExt; - - use crate::app::fixtures::{body_bytes, state_for}; - use crate::app::router; - - /// Asserts a static UI route answers 200 with the expected content type - /// and a non-empty body. - async fn assert_ui_asset(uri: &str, expected_content_type: &str) { - let (state, _state_dir) = state_for("http://127.0.0.1:1"); - let request = Request::builder() - .uri(uri) - .body(Body::empty()) - .expect("static request parts are valid"); - let response = router(state) - .oneshot(request) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::OK, "{uri} serves"); - let content_type = response - .headers() - .get(header::CONTENT_TYPE) - .unwrap_or_else(|| panic!("{uri} sets content-type")); - assert_eq!(content_type, expected_content_type, "{uri} content type"); - assert!( - !body_bytes(response).await.is_empty(), - "{uri} body is non-empty" - ); - } - - /// Every asset must force revalidation: the bundle is unversioned, so - /// a heuristic cache with no validator serves a stale script against a - /// newer server. - #[tokio::test] - async fn every_asset_forces_revalidation() { - for uri in [ - "/", - "/app.js", - "/style.css", - "/app.css", - "/pcm-worklet.js", - "/icons/promptforge-icon.png", - "/icons/promptforge-icon@2x.png", - ] { - let (state, _state_dir) = state_for("http://127.0.0.1:1"); - let request = Request::builder() - .uri(uri) - .body(Body::empty()) - .expect("static request parts are valid"); - let response = router(state).oneshot(request).await.expect("infallible"); - let cache_control = response - .headers() - .get(header::CACHE_CONTROL) - .unwrap_or_else(|| panic!("{uri} sets cache-control")); - assert_eq!(cache_control, "no-cache", "{uri} cache-control"); - } - } - - #[tokio::test] - async fn index_is_served_at_the_root() { - assert_ui_asset("/", "text/html; charset=utf-8").await; - } - - #[tokio::test] - async fn app_js_is_served_as_javascript() { - assert_ui_asset("/app.js", "text/javascript; charset=utf-8").await; - } - - #[tokio::test] - async fn style_css_is_served_as_css() { - assert_ui_asset("/style.css", "text/css; charset=utf-8").await; - } - - #[tokio::test] - async fn bundled_app_css_is_served_as_css() { - assert_ui_asset("/app.css", "text/css; charset=utf-8").await; - } - - #[tokio::test] - async fn pcm_worklet_is_served_as_javascript() { - assert_ui_asset("/pcm-worklet.js", "text/javascript; charset=utf-8").await; - } - - #[tokio::test] - async fn program_icon_is_served_as_png() { - assert_ui_asset("/icons/promptforge-icon.png", "image/png").await; - } - - #[tokio::test] - async fn program_icon_2x_is_served_as_png() { - assert_ui_asset("/icons/promptforge-icon@2x.png", "image/png").await; - } -} +mod tests; diff --git a/crates/workshop-server/src/routes/assets/tests.rs b/crates/workshop-server/src/routes/assets/tests.rs new file mode 100644 index 00000000..bfb54867 --- /dev/null +++ b/crates/workshop-server/src/routes/assets/tests.rs @@ -0,0 +1,93 @@ +use axum::body::Body; +use axum::http::{Request, StatusCode, header}; +use tower::ServiceExt; + +use crate::app::fixtures::{body_bytes, state_for}; +use crate::app::router; + +/// Asserts a static UI route answers 200 with the expected content type +/// and a non-empty body. +async fn assert_ui_asset(uri: &str, expected_content_type: &str) { + let (state, _state_dir) = state_for("http://127.0.0.1:1"); + let request = Request::builder() + .uri(uri) + .body(Body::empty()) + .expect("static request parts are valid"); + let response = router(state) + .oneshot(request) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::OK, "{uri} serves"); + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .unwrap_or_else(|| panic!("{uri} sets content-type")); + assert_eq!(content_type, expected_content_type, "{uri} content type"); + assert!( + !body_bytes(response).await.is_empty(), + "{uri} body is non-empty" + ); +} + +/// Every asset must force revalidation: the bundle is unversioned, so +/// a heuristic cache with no validator serves a stale script against a +/// newer server. +#[tokio::test] +async fn every_asset_forces_revalidation() { + for uri in [ + "/", + "/app.js", + "/style.css", + "/app.css", + "/pcm-worklet.js", + "/icons/promptforge-icon.png", + "/icons/promptforge-icon@2x.png", + ] { + let (state, _state_dir) = state_for("http://127.0.0.1:1"); + let request = Request::builder() + .uri(uri) + .body(Body::empty()) + .expect("static request parts are valid"); + let response = router(state).oneshot(request).await.expect("infallible"); + let cache_control = response + .headers() + .get(header::CACHE_CONTROL) + .unwrap_or_else(|| panic!("{uri} sets cache-control")); + assert_eq!(cache_control, "no-cache", "{uri} cache-control"); + } +} + +#[tokio::test] +async fn index_is_served_at_the_root() { + assert_ui_asset("/", "text/html; charset=utf-8").await; +} + +#[tokio::test] +async fn app_js_is_served_as_javascript() { + assert_ui_asset("/app.js", "text/javascript; charset=utf-8").await; +} + +#[tokio::test] +async fn style_css_is_served_as_css() { + assert_ui_asset("/style.css", "text/css; charset=utf-8").await; +} + +#[tokio::test] +async fn bundled_app_css_is_served_as_css() { + assert_ui_asset("/app.css", "text/css; charset=utf-8").await; +} + +#[tokio::test] +async fn pcm_worklet_is_served_as_javascript() { + assert_ui_asset("/pcm-worklet.js", "text/javascript; charset=utf-8").await; +} + +#[tokio::test] +async fn program_icon_is_served_as_png() { + assert_ui_asset("/icons/promptforge-icon.png", "image/png").await; +} + +#[tokio::test] +async fn program_icon_2x_is_served_as_png() { + assert_ui_asset("/icons/promptforge-icon@2x.png", "image/png").await; +} diff --git a/crates/workshop-server/src/routes/gateway_config.rs b/crates/workshop-server/src/routes/gateway_config.rs index ad6bbbda..f74f96c0 100644 --- a/crates/workshop-server/src/routes/gateway_config.rs +++ b/crates/workshop-server/src/routes/gateway_config.rs @@ -92,7 +92,8 @@ fn forward_allowed(method: &Method, path: &str) -> bool { /// Answers the gateway's base URL, so the workshop UI can point the /// config panel's iframe at `/config/?mode=panel`. async fn gateway_origin(State(state): State) -> Response { - let body = serde_json::json!({ "origin": state.gateway_client().base_url() }); + let gateway = state.gateway_snapshot(); + let body = serde_json::json!({ "origin": gateway.base_url() }); ( StatusCode::OK, [(header::CONTENT_TYPE, "application/json")], @@ -124,8 +125,9 @@ async fn gateway_config_assets( /// The shared proxy core: GET the gateway's config asset and relay it. async fn proxy_config_asset(state: &AppState, path: &str) -> Result { - let forwarded = state - .gateway_client() + let gateway = state.gateway_snapshot(); + let forwarded = gateway + .client() .forward(reqwest::Method::GET, path, None) .await .map_err(AppError::Gateway)?; @@ -164,8 +166,9 @@ async fn gateway_forward( // name reqwest cannot represent is refused rather than forwarded. let method = reqwest::Method::from_bytes(method.as_str().as_bytes()) .map_err(|_| AppError::ForwardDenied)?; - let forwarded = state - .gateway_client() + let gateway = state.gateway_snapshot(); + let forwarded = gateway + .client() .forward( method, &path_and_query, @@ -187,248 +190,4 @@ async fn gateway_forward( } #[cfg(test)] -mod tests { - use super::*; - - use axum::body::Body; - use axum::http::Request; - use axum::response::IntoResponse; - use axum::routing::{get as axum_get, put as axum_put}; - use tower::ServiceExt; - - use crate::app::fixtures::{body_bytes, spawn_gateway, state_for}; - use crate::app::router; - - #[test] - fn the_allowlist_admits_the_config_surface_and_refuses_the_rest() { - for (method, path) in [ - (Method::GET, "/admin/config"), - (Method::GET, "/admin/chat-templates"), - (Method::PUT, "/admin/config"), - (Method::POST, "/admin/config-apply"), - (Method::POST, "/admin/config-revert"), - (Method::POST, "/admin/queue/cancel"), - (Method::POST, "/admin/queue/cancel-pending"), - (Method::GET, "/admin/status"), - (Method::GET, "/admin/hf/search"), - (Method::GET, "/v1/cache"), - ( - Method::DELETE, - "/v1/cache/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - ), - ] { - assert!( - forward_allowed(&method, path), - "{method} {path} must be forwardable" - ); - } - for (method, path) in [ - (Method::POST, "/v1/cache"), - (Method::GET, "/v1/models"), - (Method::POST, "/v1/chat/completions"), - (Method::GET, "/admin/progress"), - (Method::PUT, "/admin/boot-config"), - (Method::PUT, "/admin/include/common.toml"), - (Method::POST, "/admin/profiles/beta"), - (Method::POST, "/admin/switch-profile"), - (Method::GET, "/health"), - (Method::GET, "/config/"), - (Method::GET, "/admin/hf/../../v1/chat/completions"), - (Method::GET, "/admin/hf/..\\..\\v1\\chat\\completions"), - (Method::GET, "/admin/hf/./search"), - (Method::GET, "/admin"), - (Method::DELETE, "/v1/cache/abc123"), - ] { - assert!( - !forward_allowed(&method, path), - "{method} {path} must be refused" - ); - } - } - - #[tokio::test] - async fn the_origin_route_answers_the_configured_gateway_base_url() { - let (state, _state_dir) = state_for("http://127.0.0.1:8081"); - let request = Request::builder() - .uri("/gateway/origin") - .body(Body::empty()) - .expect("static request parts are valid"); - let response = router(state) - .oneshot(request) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::OK); - let json: serde_json::Value = - serde_json::from_slice(&body_bytes(response).await).expect("the body is JSON"); - assert_eq!(json["origin"], "http://127.0.0.1:8081"); - } - - #[tokio::test] - async fn the_proxy_forwards_an_allowlisted_path_with_the_bearer_key() { - let gateway = axum::Router::new().route( - "/admin/status", - axum_get(|headers: axum::http::HeaderMap| async move { - let authorized = headers - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - == Some("Bearer test-key"); - ( - [(header::CONTENT_TYPE, "application/json")], - format!(r#"{{"profile":"default","authorized":{authorized}}}"#), - ) - }), - ); - let base_url = spawn_gateway(gateway).await; - let (state, _state_dir) = state_for(&base_url); - let request = Request::builder() - .uri("/gateway/api/admin/status") - .body(Body::empty()) - .expect("static request parts are valid"); - let response = router(state) - .oneshot(request) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response - .headers() - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()), - Some("application/json"), - "the gateway's content type is relayed" - ); - let json: serde_json::Value = - serde_json::from_slice(&body_bytes(response).await).expect("the body is JSON"); - assert_eq!(json["profile"], "default", "the body is relayed verbatim"); - assert_eq!( - json["authorized"], true, - "the forward carries the workshop's bearer key" - ); - } - - #[tokio::test] - async fn the_proxied_config_assets_force_revalidation() { - let gateway = axum::Router::new().route( - "/config/app.js", - axum_get(|| async move { - ( - [(header::CONTENT_TYPE, "text/javascript; charset=utf-8")], - "// bundle", - ) - }), - ); - let base_url = spawn_gateway(gateway).await; - let (state, _state_dir) = state_for(&base_url); - let request = Request::builder() - .uri("/gateway/config/app.js") - .body(Body::empty()) - .expect("static request parts are valid"); - let response = router(state) - .oneshot(request) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::OK); - // The relayed bundle is unversioned; without this the panel's - // WebView2 serves a cached script against a newer gateway. - assert_eq!( - response - .headers() - .get(header::CACHE_CONTROL) - .and_then(|value| value.to_str().ok()), - Some("no-cache"), - "the relay forces revalidation" - ); - } - - #[tokio::test] - async fn the_proxy_forwards_the_query_string_and_a_json_body() { - let gateway = axum::Router::new().route( - "/admin/config", - axum_put( - |headers: axum::http::HeaderMap, request: axum::extract::Request| async move { - let declared_json = headers - .get(header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - == Some("application/json"); - let body = axum::body::to_bytes(request.into_body(), usize::MAX) - .await - .unwrap_or_default(); - ( - [(header::CONTENT_TYPE, "application/json")], - format!( - r#"{{"declared_json":{declared_json},"echo":{}}}"#, - String::from_utf8_lossy(&body) - ), - ) - .into_response() - }, - ), - ); - let base_url = spawn_gateway(gateway).await; - let (state, _state_dir) = state_for(&base_url); - let request = Request::builder() - .method("PUT") - .uri("/gateway/api/admin/config?source=panel") - .header(header::CONTENT_TYPE, "application/json") - .body(Body::from(r#"{"active_profile":"beta"}"#)) - .expect("static request parts are valid"); - let response = router(state) - .oneshot(request) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::OK); - let json: serde_json::Value = - serde_json::from_slice(&body_bytes(response).await).expect("the body is JSON"); - assert_eq!(json["declared_json"], true, "the body forwards as JSON"); - assert_eq!( - json["echo"]["active_profile"], "beta", - "the body forwards verbatim" - ); - } - - #[tokio::test] - async fn the_proxy_refuses_a_non_allowlisted_path_without_dialing() { - // An unroutable gateway address: a refused path must answer 403 - // before any dial, so no transport error can occur. - let (state, _state_dir) = state_for("http://127.0.0.1:1"); - for path in [ - "/gateway/api/v1/chat/completions", - "/gateway/api/admin/progress", - "/gateway/api/admin/hf/../../v1/chat/completions", - ] { - let request = Request::builder() - .uri(path) - .body(Body::empty()) - .expect("static request parts are valid"); - let response = router(state.clone()) - .oneshot(request) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::FORBIDDEN, "for {path}"); - let json: serde_json::Value = - serde_json::from_slice(&body_bytes(response).await).expect("the body is JSON"); - assert_eq!(json["error"]["code"], "forward_denied", "for {path}"); - } - } - - #[tokio::test] - async fn the_proxy_sits_behind_the_cross_site_guard() { - // The workshop listener binds loopback only; on top of that the - // cross-site guard refuses a DNS-rebound Host, so the proxy is - // covered by the same wall as the rest of the API surface. - let (state, _state_dir) = state_for("http://127.0.0.1:1"); - let request = Request::builder() - .uri("/gateway/api/admin/status") - .header("host", "rebound.example:7910") - .body(Body::empty()) - .expect("static request parts are valid"); - let response = router(state) - .oneshot(request) - .await - .expect("the router is infallible"); - assert_eq!(response.status(), StatusCode::FORBIDDEN); - let json: serde_json::Value = - serde_json::from_slice(&body_bytes(response).await).expect("the body is JSON"); - assert_eq!(json["error"]["code"], "cross_site"); - } -} +mod tests; diff --git a/crates/workshop-server/src/routes/gateway_config/tests.rs b/crates/workshop-server/src/routes/gateway_config/tests.rs new file mode 100644 index 00000000..da15fa5c --- /dev/null +++ b/crates/workshop-server/src/routes/gateway_config/tests.rs @@ -0,0 +1,245 @@ +use super::*; + +use axum::body::Body; +use axum::http::Request; +use axum::response::IntoResponse; +use axum::routing::{get as axum_get, put as axum_put}; +use tower::ServiceExt; + +use crate::app::fixtures::{body_bytes, spawn_gateway, state_for}; +use crate::app::router; + +mod recovery; + +#[test] +fn the_allowlist_admits_the_config_surface_and_refuses_the_rest() { + for (method, path) in [ + (Method::GET, "/admin/config"), + (Method::GET, "/admin/chat-templates"), + (Method::PUT, "/admin/config"), + (Method::POST, "/admin/config-apply"), + (Method::POST, "/admin/config-revert"), + (Method::POST, "/admin/queue/cancel"), + (Method::POST, "/admin/queue/cancel-pending"), + (Method::GET, "/admin/status"), + (Method::GET, "/admin/hf/search"), + (Method::GET, "/v1/cache"), + ( + Method::DELETE, + "/v1/cache/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ), + ] { + assert!( + forward_allowed(&method, path), + "{method} {path} must be forwardable" + ); + } + for (method, path) in [ + (Method::POST, "/v1/cache"), + (Method::GET, "/v1/models"), + (Method::POST, "/v1/chat/completions"), + (Method::GET, "/admin/progress"), + (Method::PUT, "/admin/boot-config"), + (Method::PUT, "/admin/include/common.toml"), + (Method::POST, "/admin/profiles/beta"), + (Method::POST, "/admin/switch-profile"), + (Method::GET, "/health"), + (Method::GET, "/config/"), + (Method::GET, "/admin/hf/../../v1/chat/completions"), + (Method::GET, "/admin/hf/..\\..\\v1\\chat\\completions"), + (Method::GET, "/admin/hf/./search"), + (Method::GET, "/admin"), + (Method::DELETE, "/v1/cache/abc123"), + ] { + assert!( + !forward_allowed(&method, path), + "{method} {path} must be refused" + ); + } +} + +#[tokio::test] +async fn the_origin_route_answers_the_configured_gateway_base_url() { + let (state, _state_dir) = state_for("http://127.0.0.1:8081"); + let request = Request::builder() + .uri("/gateway/origin") + .body(Body::empty()) + .expect("static request parts are valid"); + let response = router(state) + .oneshot(request) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::OK); + let json: serde_json::Value = + serde_json::from_slice(&body_bytes(response).await).expect("the body is JSON"); + assert_eq!(json["origin"], "http://127.0.0.1:8081"); +} + +#[tokio::test] +async fn the_proxy_forwards_an_allowlisted_path_with_the_bearer_key() { + let gateway = axum::Router::new().route( + "/admin/status", + axum_get(|headers: axum::http::HeaderMap| async move { + let authorized = headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + == Some("Bearer test-key"); + ( + [(header::CONTENT_TYPE, "application/json")], + format!(r#"{{"profile":"default","authorized":{authorized}}}"#), + ) + }), + ); + let base_url = spawn_gateway(gateway).await; + let (state, _state_dir) = state_for(&base_url); + let request = Request::builder() + .uri("/gateway/api/admin/status") + .body(Body::empty()) + .expect("static request parts are valid"); + let response = router(state) + .oneshot(request) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/json"), + "the gateway's content type is relayed" + ); + let json: serde_json::Value = + serde_json::from_slice(&body_bytes(response).await).expect("the body is JSON"); + assert_eq!(json["profile"], "default", "the body is relayed verbatim"); + assert_eq!( + json["authorized"], true, + "the forward carries the workshop's bearer key" + ); +} + +#[tokio::test] +async fn the_proxied_config_assets_force_revalidation() { + let gateway = axum::Router::new().route( + "/config/app.js", + axum_get(|| async move { + ( + [(header::CONTENT_TYPE, "text/javascript; charset=utf-8")], + "// bundle", + ) + }), + ); + let base_url = spawn_gateway(gateway).await; + let (state, _state_dir) = state_for(&base_url); + let request = Request::builder() + .uri("/gateway/config/app.js") + .body(Body::empty()) + .expect("static request parts are valid"); + let response = router(state) + .oneshot(request) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::OK); + // The relayed bundle is unversioned; without this the panel's + // WebView2 serves a cached script against a newer gateway. + assert_eq!( + response + .headers() + .get(header::CACHE_CONTROL) + .and_then(|value| value.to_str().ok()), + Some("no-cache"), + "the relay forces revalidation" + ); +} + +#[tokio::test] +async fn the_proxy_forwards_the_query_string_and_a_json_body() { + let gateway = axum::Router::new().route( + "/admin/config", + axum_put( + |headers: axum::http::HeaderMap, request: axum::extract::Request| async move { + let declared_json = headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + == Some("application/json"); + let body = axum::body::to_bytes(request.into_body(), usize::MAX) + .await + .unwrap_or_default(); + ( + [(header::CONTENT_TYPE, "application/json")], + format!( + r#"{{"declared_json":{declared_json},"echo":{}}}"#, + String::from_utf8_lossy(&body) + ), + ) + .into_response() + }, + ), + ); + let base_url = spawn_gateway(gateway).await; + let (state, _state_dir) = state_for(&base_url); + let request = Request::builder() + .method("PUT") + .uri("/gateway/api/admin/config?source=panel") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"active_profile":"beta"}"#)) + .expect("static request parts are valid"); + let response = router(state) + .oneshot(request) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::OK); + let json: serde_json::Value = + serde_json::from_slice(&body_bytes(response).await).expect("the body is JSON"); + assert_eq!(json["declared_json"], true, "the body forwards as JSON"); + assert_eq!( + json["echo"]["active_profile"], "beta", + "the body forwards verbatim" + ); +} + +#[tokio::test] +async fn the_proxy_refuses_a_non_allowlisted_path_without_dialing() { + // An unroutable gateway address: a refused path must answer 403 + // before any dial, so no transport error can occur. + let (state, _state_dir) = state_for("http://127.0.0.1:1"); + for path in [ + "/gateway/api/v1/chat/completions", + "/gateway/api/admin/progress", + "/gateway/api/admin/hf/../../v1/chat/completions", + ] { + let request = Request::builder() + .uri(path) + .body(Body::empty()) + .expect("static request parts are valid"); + let response = router(state.clone()) + .oneshot(request) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::FORBIDDEN, "for {path}"); + let json: serde_json::Value = + serde_json::from_slice(&body_bytes(response).await).expect("the body is JSON"); + assert_eq!(json["error"]["code"], "forward_denied", "for {path}"); + } +} + +#[tokio::test] +async fn the_proxy_sits_behind_the_cross_site_guard() { + // The workshop listener binds loopback only; on top of that the + // cross-site guard refuses a DNS-rebound Host, so the proxy is + // covered by the same wall as the rest of the API surface. + let (state, _state_dir) = state_for("http://127.0.0.1:1"); + let request = Request::builder() + .uri("/gateway/api/admin/status") + .header("host", "rebound.example:7910") + .body(Body::empty()) + .expect("static request parts are valid"); + let response = router(state) + .oneshot(request) + .await + .expect("the router is infallible"); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let json: serde_json::Value = + serde_json::from_slice(&body_bytes(response).await).expect("the body is JSON"); + assert_eq!(json["error"]["code"], "cross_site"); +} diff --git a/crates/workshop-server/src/routes/gateway_config/tests/recovery.rs b/crates/workshop-server/src/routes/gateway_config/tests/recovery.rs new file mode 100644 index 00000000..1a12115b --- /dev/null +++ b/crates/workshop-server/src/routes/gateway_config/tests/recovery.rs @@ -0,0 +1,55 @@ +use super::*; + +#[tokio::test] +async fn origin_and_config_proxy_follow_one_replacement_snapshot() { + let gateway = axum::Router::new().route( + "/admin/status", + axum_get(|headers: axum::http::HeaderMap| async move { + if headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + == Some("Bearer replacement-key") + { + StatusCode::OK + } else { + StatusCode::UNAUTHORIZED + } + }), + ); + let replacement = spawn_gateway(gateway).await; + let (state, _state_dir) = state_for("http://127.0.0.1:1"); + state + .gateway_binding() + .replace(&replacement, "replacement-key") + .expect("the replacement publishes"); + let app = router(state); + + let origin = app + .clone() + .oneshot( + Request::builder() + .uri("/gateway/origin") + .body(Body::empty()) + .expect("the origin request builds"), + ) + .await + .expect("the router is infallible"); + let json: serde_json::Value = + serde_json::from_slice(&body_bytes(origin).await).expect("the origin body is JSON"); + assert_eq!(json["origin"], replacement); + + let proxied = app + .oneshot( + Request::builder() + .uri("/gateway/api/admin/status") + .body(Body::empty()) + .expect("the proxy request builds"), + ) + .await + .expect("the router is infallible"); + assert_eq!( + proxied.status(), + StatusCode::OK, + "the proxy uses the replacement bearer with the replacement URL" + ); +} diff --git a/crates/workshop-server/src/routes/realtime.rs b/crates/workshop-server/src/routes/realtime.rs new file mode 100644 index 00000000..288c0b97 --- /dev/null +++ b/crates/workshop-server/src/routes/realtime.rs @@ -0,0 +1,182 @@ +//! Same-origin, payload-opaque relay for Gateway Realtime transcription. + +use std::time::Duration; + +use axum::Router; +use axum::extract::State; +use axum::extract::ws::{CloseFrame as BrowserCloseFrame, Message as BrowserMessage}; +use axum::extract::ws::{WebSocket, WebSocketUpgrade}; +use axum::http::{HeaderMap, StatusCode, Uri, header}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use futures_util::{SinkExt as _, StreamExt as _}; +use tokio_tungstenite::tungstenite::Message as GatewayMessage; +use tokio_tungstenite::tungstenite::protocol::CloseFrame as GatewayCloseFrame; + +use crate::app::AppState; +use crate::gateway::GatewayRealtimeSocket; + +const RELAY_IO_DEADLINE: Duration = Duration::from_millis(500); + +/// The Workshop endpoint mirroring Gateway Realtime transcription. +pub(crate) fn routes(state: AppState) -> Router { + Router::new() + .route("/v1/realtime", get(upgrade)) + .with_state(state) +} + +async fn upgrade( + State(state): State, + uri: Uri, + headers: HeaderMap, + ws: WebSocketUpgrade, +) -> Response { + if !same_origin_allowed(&uri, &headers) { + return StatusCode::FORBIDDEN.into_response(); + } + if ws.requested_protocols().next().is_some() { + return StatusCode::BAD_REQUEST.into_response(); + } + let gateway = state.gateway_snapshot(); + match gateway.client().connect_realtime().await { + Ok(gateway) => ws.on_upgrade(move |browser| relay(browser, gateway)), + Err(error) => { + tracing::warn!(%error, "could not connect the Workshop Realtime relay to the gateway"); + StatusCode::BAD_GATEWAY.into_response() + } + } +} + +fn same_origin_allowed(uri: &Uri, headers: &HeaderMap) -> bool { + let Ok(origin) = single_header(headers, header::ORIGIN) else { + return false; + }; + let authority = match uri.authority() { + Some(authority) => Some(authority.as_str()), + None => match single_header(headers, header::HOST) { + Ok(authority) => authority, + Err(()) => return false, + }, + }; + shared_loopback::workshop_same_origin_authority_allowed(origin, authority) +} + +fn single_header(headers: &HeaderMap, name: header::HeaderName) -> Result, ()> { + let mut values = headers.get_all(name).iter(); + let Some(first) = values.next() else { + return Ok(None); + }; + if values.next().is_some() { + return Err(()); + } + first.to_str().map(Some).map_err(|_| ()) +} + +async fn relay(mut browser: WebSocket, mut gateway: GatewayRealtimeSocket) { + loop { + tokio::select! { + gateway_frame = gateway.next() => { + let Some(Ok(frame)) = gateway_frame else { + close_browser(&mut browser).await; + return; + }; + match frame { + GatewayMessage::Text(text) => { + if !send_browser(&mut browser, BrowserMessage::Text(text.to_string().into())).await { + return; + } + } + GatewayMessage::Binary(bytes) => { + if !send_browser(&mut browser, BrowserMessage::Binary(bytes.to_vec().into())).await { + return; + } + } + GatewayMessage::Ping(_) => { + if !flush_gateway(&mut gateway).await { + return; + } + } + GatewayMessage::Pong(_) | GatewayMessage::Frame(_) => {} + GatewayMessage::Close(frame) => { + let outgoing = BrowserMessage::Close(frame.map(|frame| BrowserCloseFrame { + code: frame.code.into(), + reason: frame.reason.to_string().into(), + })); + let _sent = send_browser(&mut browser, outgoing).await; + let _flushed = flush_gateway(&mut gateway).await; + return; + } + } + } + browser_frame = browser.recv() => { + let Some(Ok(frame)) = browser_frame else { + close_gateway(&mut gateway).await; + return; + }; + match frame { + BrowserMessage::Text(text) => { + if !send_gateway(&mut gateway, GatewayMessage::Text(text.to_string().into())).await { + return; + } + } + BrowserMessage::Binary(bytes) => { + if !send_gateway(&mut gateway, GatewayMessage::Binary(bytes.to_vec().into())).await { + return; + } + } + BrowserMessage::Ping(_) => { + if !flush_browser(&mut browser).await { + return; + } + } + BrowserMessage::Pong(_) => {} + BrowserMessage::Close(frame) => { + let outgoing = GatewayMessage::Close(frame.map(|frame| GatewayCloseFrame { + code: frame.code.into(), + reason: frame.reason.to_string().into(), + })); + let _sent = send_gateway(&mut gateway, outgoing).await; + let _flushed = flush_browser(&mut browser).await; + return; + } + } + } + } + } +} + +async fn send_browser(browser: &mut WebSocket, message: BrowserMessage) -> bool { + matches!( + tokio::time::timeout(RELAY_IO_DEADLINE, browser.send(message)).await, + Ok(Ok(())) + ) +} + +async fn send_gateway(gateway: &mut GatewayRealtimeSocket, message: GatewayMessage) -> bool { + matches!( + tokio::time::timeout(RELAY_IO_DEADLINE, gateway.send(message)).await, + Ok(Ok(())) + ) +} + +async fn flush_browser(browser: &mut WebSocket) -> bool { + matches!( + tokio::time::timeout(RELAY_IO_DEADLINE, browser.flush()).await, + Ok(Ok(())) + ) +} + +async fn flush_gateway(gateway: &mut GatewayRealtimeSocket) -> bool { + matches!( + tokio::time::timeout(RELAY_IO_DEADLINE, gateway.flush()).await, + Ok(Ok(())) + ) +} + +async fn close_browser(browser: &mut WebSocket) { + let _bounded = tokio::time::timeout(RELAY_IO_DEADLINE, browser.close()).await; +} + +async fn close_gateway(gateway: &mut GatewayRealtimeSocket) { + let _bounded = tokio::time::timeout(RELAY_IO_DEADLINE, gateway.close(None)).await; +} diff --git a/crates/workshop-server/src/routes/stt.rs b/crates/workshop-server/src/routes/stt.rs deleted file mode 100644 index 388c3830..00000000 --- a/crates/workshop-server/src/routes/stt.rs +++ /dev/null @@ -1,298 +0,0 @@ -//! Same-origin relay for the gateway-owned speech-to-text routes. - -use axum::Router; -use axum::body::Body; -use axum::extract::State; -use axum::extract::ws::{Message as BrowserMessage, WebSocket, WebSocketUpgrade}; -use axum::http::{HeaderMap, StatusCode, header}; -use axum::response::{IntoResponse, Response}; -use axum::routing::get; -use futures_util::{SinkExt as _, StreamExt as _}; -use serde::Deserialize; -use tokio_tungstenite::tungstenite::Message as GatewayMessage; - -use crate::app::AppState; -use crate::error::AppError; -use crate::gateway::GatewaySttSocket; -use crate::{Activity, Push, origin_allowed}; - -/// The same-origin STT routes consumed by the Workshop UI. -pub(crate) fn routes(state: AppState) -> Router { - Router::new() - .route("/stt/capability", get(capability)) - .route("/stt", get(upgrade)) - .with_state(state) -} - -async fn capability(State(state): State) -> Result { - let forwarded = state - .gateway_client() - .forward(reqwest::Method::GET, "/stt/capability", None) - .await - .map_err(AppError::Gateway)?; - let status = StatusCode::from_u16(forwarded.status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); - let mut response = Response::new(Body::from(forwarded.body)); - *response.status_mut() = status; - if let Some(content_type) = forwarded.content_type - && let Ok(value) = content_type.parse() - { - response.headers_mut().insert(header::CONTENT_TYPE, value); - } - Ok(response) -} - -async fn upgrade( - State(state): State, - headers: HeaderMap, - ws: WebSocketUpgrade, -) -> Response { - if !origin_allowed(&headers) { - return StatusCode::FORBIDDEN.into_response(); - } - match state.gateway_client().connect_stt().await { - Ok(gateway) => { - let push = state.push(); - ws.on_upgrade(move |browser| relay(browser, gateway, push)) - } - Err(error) => { - tracing::warn!(%error, "could not connect the Workshop STT relay to the gateway"); - state.push().push_failure( - "Dictation connection failed", - error.to_string(), - Activity::General, - ); - StatusCode::BAD_GATEWAY.into_response() - } - } -} - -#[derive(Debug, Deserialize)] -struct RelayedStatusFrame { - #[serde(rename = "type")] - kind: String, - label: String, - description: String, - severity: String, -} - -fn consume_status(text: &str, push: &Push) -> bool { - let Ok(status) = serde_json::from_str::(text) else { - return false; - }; - if status.kind != "workshop_status" { - return false; - } - match status.severity.as_str() { - "info" => push.push_status_update(status.label, status.description, Activity::General), - "debug" => push.push_activity(status.label, status.description, Activity::General), - "error" => push.push_failure(status.label, status.description, Activity::General), - severity => tracing::warn!(severity, "gateway sent an unknown STT status severity"), - } - true -} - -async fn relay(mut browser: WebSocket, mut gateway: GatewaySttSocket, push: Push) { - loop { - tokio::select! { - browser_frame = browser.recv() => { - let Some(Ok(frame)) = browser_frame else { - break; - }; - let outgoing = match frame { - BrowserMessage::Text(text) => GatewayMessage::Text(text.to_string().into()), - BrowserMessage::Binary(bytes) => GatewayMessage::Binary(bytes.to_vec().into()), - BrowserMessage::Ping(bytes) => GatewayMessage::Ping(bytes.to_vec().into()), - BrowserMessage::Pong(bytes) => GatewayMessage::Pong(bytes.to_vec().into()), - BrowserMessage::Close(_) => break, - }; - if gateway.send(outgoing).await.is_err() { - break; - } - } - gateway_frame = gateway.next() => { - let Some(Ok(frame)) = gateway_frame else { - break; - }; - let outgoing = match frame { - GatewayMessage::Text(text) => { - if consume_status(&text, &push) { - continue; - } - BrowserMessage::Text(text.to_string().into()) - } - GatewayMessage::Binary(bytes) => BrowserMessage::Binary(bytes.to_vec().into()), - GatewayMessage::Ping(bytes) => BrowserMessage::Ping(bytes.to_vec().into()), - GatewayMessage::Pong(bytes) => BrowserMessage::Pong(bytes.to_vec().into()), - GatewayMessage::Close(_) => break, - GatewayMessage::Frame(_) => continue, - }; - if browser.send(outgoing).await.is_err() { - break; - } - } - } - } - let _ = gateway.close(None).await; - let _ = browser.close().await; - push.push_idle(); -} - -#[cfg(test)] -mod tests { - use axum::extract::ws::{Message, WebSocketUpgrade}; - use axum::http::{Request, header}; - use axum::routing::get; - use futures_util::{SinkExt as _, StreamExt as _}; - use tokio_tungstenite::tungstenite::Message as ClientMessage; - use tower::ServiceExt as _; - - use super::*; - use crate::app::fixtures::{body_bytes, config_for, spawn_gateway, state_for}; - use crate::resolve::ResolvedGateway; - - async fn mock_capability(headers: HeaderMap) -> Response { - if headers - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - != Some("Bearer test-key") - { - return StatusCode::UNAUTHORIZED.into_response(); - } - ( - [(header::CONTENT_TYPE, "application/json")], - r#"{"gpu":true,"engine":true}"#, - ) - .into_response() - } - - async fn mock_socket(headers: HeaderMap, ws: WebSocketUpgrade) -> Response { - if headers - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - != Some("Bearer test-key") - || headers - .get("x-promptforge-workshop-status") - .and_then(|value| value.to_str().ok()) - != Some("1") - { - return StatusCode::UNAUTHORIZED.into_response(); - } - ws.on_upgrade(|mut socket| async move { - while let Some(Ok(message)) = socket.recv().await { - match message { - Message::Text(_) | Message::Binary(_) => { - if matches!(&message, Message::Text(text) if text.as_str() == "start") - && socket - .send(Message::Text( - r#"{"type":"workshop_status","label":"Relay listening","description":"private status","severity":"info"}"# - .into(), - )) - .await - .is_err() - { - return; - } - if socket.send(message).await.is_err() { - return; - } - } - Message::Close(_) => return, - Message::Ping(_) | Message::Pong(_) => {} - } - } - }) - } - - #[tokio::test] - async fn capability_is_relayed_with_the_gateway_key() { - let gateway = - spawn_gateway(Router::new().route("/stt/capability", get(mock_capability))).await; - let (state, _state_dir) = state_for(&gateway); - let response = routes(state) - .oneshot( - Request::builder() - .uri("/stt/capability") - .body(Body::empty()) - .expect("request builds"), - ) - .await - .expect("route responds"); - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::CONTENT_TYPE), - Some(&"application/json".parse().expect("header value parses")) - ); - assert_eq!(body_bytes(response).await, r#"{"gpu":true,"engine":true}"#); - } - - #[tokio::test] - async fn websocket_frames_cross_the_authenticated_relay() { - let gateway = spawn_gateway(Router::new().route("/stt", get(mock_socket))).await; - let state_dir = tempfile::TempDir::new().expect("tempdir"); - let mut config = config_for(&gateway, state_dir.path()); - config.server.bind = "127.0.0.1:0".to_owned(); - let resolved = ResolvedGateway::from_config(&config.gateway); - let server = crate::spawn_with_routes(config, resolved, |_| Router::new()) - .expect("Workshop server starts"); - let address = server - .url() - .strip_prefix("http") - .expect("Workshop URL is HTTP"); - let (mut observer, _response) = tokio_tungstenite::connect_async(format!("ws{address}/ws")) - .await - .expect("status observer connects"); - let (mut socket, _response) = tokio_tungstenite::connect_async(format!("ws{address}/stt")) - .await - .expect("browser-side socket connects"); - - socket - .send(ClientMessage::Text("start".into())) - .await - .expect("text frame sends"); - assert_eq!( - socket - .next() - .await - .expect("reply arrives") - .expect("reply is valid"), - ClientMessage::Text("start".into()) - ); - let status = tokio::time::timeout(std::time::Duration::from_secs(2), async { - loop { - let message = observer - .next() - .await - .expect("status socket stays open") - .expect("status frame is valid"); - let ClientMessage::Text(text) = message else { - continue; - }; - let Ok(frame) = serde_json::from_str::(&text) else { - continue; - }; - if frame["type"] == "status" && frame["label"] == "Relay listening" { - break frame; - } - } - }) - .await - .expect("relayed status reaches the Workshop observer"); - assert_eq!(status["description"], "private status"); - socket - .send(ClientMessage::Binary(vec![1, 2, 3].into())) - .await - .expect("binary frame sends"); - assert_eq!( - socket - .next() - .await - .expect("reply arrives") - .expect("reply is valid"), - ClientMessage::Binary(vec![1, 2, 3].into()) - ); - - socket.close(None).await.expect("socket closes"); - observer.close(None).await.expect("observer closes"); - server.shutdown().expect("Workshop server stops"); - } -} diff --git a/crates/workshop-server/src/serve.rs b/crates/workshop-server/src/serve.rs index 6fae0916..c2419866 100644 --- a/crates/workshop-server/src/serve.rs +++ b/crates/workshop-server/src/serve.rs @@ -14,8 +14,9 @@ use std::sync::mpsc; use std::thread::JoinHandle; use std::time::Duration; -use crate::app::{AppState, StateError, router, state_with_gateway}; +use crate::app::{StateError, router, state_with_gateway}; use crate::config::Config; +use crate::gateway_binding::GatewayUpdater; use crate::gateway_progress; use crate::heartbeat; use crate::progress; @@ -34,8 +35,6 @@ const SHUTDOWN_GRACE: Duration = Duration::from_secs(5); /// open indefinitely. const RUNTIME_TEARDOWN: Duration = Duration::from_secs(1); -type RouteFactory = Box axum::Router + Send>; - /// How a [`ServerHandle::shutdown`] ended. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] @@ -54,6 +53,7 @@ pub enum Termination { #[derive(Debug)] pub struct ServerHandle { url: String, + gateway: GatewayUpdater, shutdown: Option>, stopped: mpsc::Receiver, thread: Option>>, @@ -67,6 +67,13 @@ impl ServerHandle { &self.url } + /// Returns the restricted publisher used by an embedding desktop host + /// to atomically replace a relaunched local sidecar's port and bearer. + #[must_use] + pub fn gateway_updater(&self) -> GatewayUpdater { + self.gateway.clone() + } + /// Signals shutdown and waits for the server thread to finish, /// reporting how the stop ended. /// @@ -148,35 +155,13 @@ pub enum SpawnError { /// or the shared state cannot be built, and [`SpawnError::Io`] if the /// bind fails or the server thread cannot be spawned. pub fn spawn(config: Config) -> Result { - spawn_inner( - config, - None, - SHUTDOWN_GRACE, - Box::new(|_| axum::Router::new()), - ) + spawn_inner(config, None, SHUTDOWN_GRACE) } -/// Spawns the workshop server against an already-resolved gateway -/// endpoint, with extra routes merged into its loopback listener. -/// -/// `gateway` skips connection-file discovery: a host attaching routes -/// holds its own endpoint, and discovery must never condemn that file -/// or attach the workshop to a foreign gateway. `routes` runs after -/// shared state construction on the server thread. It receives the state -/// so an owning gateway subsystem can attach to the Workshop status bus -/// without moving that subsystem into this crate. Product hosts currently -/// need no extra routes; integration fixtures use the seam to exercise -/// externally-owned route groups. -/// -/// # Errors -/// Returns [`SpawnError::State`] if shared state cannot be built, or -/// [`SpawnError::Io`] if the listener or server thread cannot start. -pub fn spawn_with_routes( - config: Config, - gateway: ResolvedGateway, - routes: impl FnOnce(&AppState) -> axum::Router + Send + 'static, -) -> Result { - spawn_inner(config, Some(gateway), SHUTDOWN_GRACE, Box::new(routes)) +#[cfg(feature = "test-fixtures")] +pub(crate) fn spawn_resolved(config: Config) -> Result { + let gateway = ResolvedGateway::from_config(&config.gateway); + spawn_inner(config, Some(gateway), SHUTDOWN_GRACE) } /// [`spawn`] with the shutdown grace window injectable, so tests prove the @@ -185,19 +170,13 @@ pub fn spawn_with_routes( #[cfg(test)] fn spawn_with_grace(config: Config, grace: Duration) -> Result { let gateway = ResolvedGateway::from_config(&config.gateway); - spawn_inner( - config, - Some(gateway), - grace, - Box::new(|_| axum::Router::new()), - ) + spawn_inner(config, Some(gateway), grace) } fn spawn_inner( config: Config, gateway: Option, grace: Duration, - routes: RouteFactory, ) -> Result { // Discovery runs before the server thread starts: a resolution // failure is the plain no-gateway error, never a bind-then-fail. @@ -210,20 +189,11 @@ fn spawn_inner( let (stopped_tx, stopped_rx) = mpsc::channel(); let thread = std::thread::Builder::new() .name("workshop-server".to_string()) - .spawn(move || { - serve_thread( - config, - gateway, - routes, - ready_tx, - shutdown_rx, - &stopped_tx, - grace, - ) - })?; + .spawn(move || serve_thread(config, gateway, ready_tx, shutdown_rx, &stopped_tx, grace))?; match ready_rx.recv() { - Ok(Ok(url)) => Ok(ServerHandle { + Ok(Ok((url, gateway))) => Ok(ServerHandle { url, + gateway, shutdown: Some(shutdown_tx), stopped: stopped_rx, thread: Some(thread), @@ -251,8 +221,7 @@ fn spawn_inner( fn serve_thread( config: Config, gateway: ResolvedGateway, - routes: RouteFactory, - ready: mpsc::Sender>, + ready: mpsc::Sender>, shutdown: tokio::sync::oneshot::Receiver<()>, stopped: &mpsc::Sender, grace: Duration, @@ -268,8 +237,6 @@ fn serve_thread( } }; let (outcome, result) = runtime.block_on(async move { - let gateway_base_url = gateway.base_url().to_string(); - let gateway_api_key = gateway.api_key().to_string(); let state = match state_with_gateway(&config, &gateway) { Ok(state) => state, Err(error) => { @@ -277,7 +244,7 @@ fn serve_thread( return (Termination::Graceful, Ok(())); } }; - let app = router(state.clone()).merge(routes(&state)); + let app = router(state.clone()); let listener = match reuse_bind(&config.server.bind) { Ok(listener) => listener, Err(error) => { @@ -289,12 +256,12 @@ fn serve_thread( Ok(address) => address, Err(error) => return (Termination::Graceful, Err(error)), }; - let _ = ready.send(Ok(format!("http://{address}"))); + let _ = ready.send(Ok((format!("http://{address}"), state.gateway_updater()))); // The heartbeat, gateway progress subscriber, and progress renderer // start with serving and stop inside the same graceful-shutdown // signal, so they never outlive the server. let heartbeat = heartbeat::spawn( - state.gateway_client().clone(), + state.gateway_binding().clone(), state.push(), state.health().clone(), heartbeat::HEARTBEAT_INTERVAL, @@ -302,8 +269,7 @@ fn serve_thread( ); let renderer = progress::spawn(std::sync::Arc::clone(state.progress()), state.push()); let subscriber = gateway_progress::spawn( - gateway_base_url, - gateway_api_key, + state.gateway_binding().clone(), std::sync::Arc::clone(state.progress()), state.health().clone(), ); diff --git a/crates/workshop-server/src/session/menu.rs b/crates/workshop-server/src/session/menu.rs index 392f1869..d48b60af 100644 --- a/crates/workshop-server/src/session/menu.rs +++ b/crates/workshop-server/src/session/menu.rs @@ -63,7 +63,7 @@ pub(super) async fn start_switch( // state, not work held on behalf of one client, so it runs to // completion (and settles the menu) even if the clicking client // disconnects mid-switch. - let client = state.gateway_client().clone(); + let client = state.gateway_snapshot().client().clone(); let push = state.push(); let name = name.to_string(); tokio::spawn(async move { diff --git a/crates/workshop-server/src/session_agents.rs b/crates/workshop-server/src/session_agents.rs index fa32fe9a..359f25ff 100644 --- a/crates/workshop-server/src/session_agents.rs +++ b/crates/workshop-server/src/session_agents.rs @@ -25,37 +25,40 @@ //! derives the same count from the event sequence itself, so both sides //! agree without sharing more than the log. +mod lifecycle; pub(crate) mod socket; +mod supervisor; use std::collections::HashMap; use std::fmt; use std::io; use std::num::NonZeroU32; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; -use promptforge_agent::{AgentConfig, AgentError, AgentLimits, run_agent_with_client}; use promptforge_core_support::cancel::CancelHandle; use promptforge_core_support::events::{CallMetrics, RuntimeEventKind, ToolCallEvent}; use promptforge_core_support::observe::{Observation, Observer}; -use promptforge_model_client::client::{ - GatewayClient as ModelClient, GatewayEndpoint, SecretString, StreamDelta, -}; +#[cfg(test)] +use promptforge_model_client::client::GatewayClient as ModelClient; +use promptforge_model_client::client::StreamDelta; use promptforge_model_client::model::{ModelCatalog, ModelDescriptor, ModelId, ThinkingMode}; -use promptforge_store::StoreRef; -use promptforge_tools::{Tool, ToolCatalog}; -use tokio::sync::broadcast; +use tokio::sync::{broadcast, mpsc}; use crate::backoff::ReconnectBackoff; -use crate::catalog::CatalogBus; -use crate::input::{UserInputTool, WaitRegistry}; +use crate::catalog::{CatalogBus, is_chat_capable}; +use crate::gateway_binding::GatewayBinding; +use crate::input::{WaitError, WaitRegistry, deliver_input_response_before_completion}; use crate::menu::MenuBus; use crate::observer::WorkshopObserver; -use crate::protocol::{Activity, AgentDeltaKind, InputFrame}; +use crate::protocol::{Activity, AgentDeltaKind, InputFrame, InputResponse}; use crate::push::Push; use crate::workspace::Workspace; +use self::lifecycle::RunLifecycle; +use self::supervisor::transition::RunId; + /// Capacity of a session's delta broadcast. Deltas are ephemeral: a /// receiver that lags loses chunks, and the completed-reply event is the /// repair path. @@ -138,11 +141,8 @@ struct Inner { agents_dir: PathBuf, /// Where session event JSONLs persist (`state_dir/sessions`). sessions_dir: PathBuf, - /// The model client agents complete through, built from the workshop - /// gateway settings; `None` when those settings cannot make a client - /// (an empty API key), which refuses launches rather than failing at - /// startup - the rest of the workshop still serves. - client: Option, + /// The atomically replaceable Gateway clients every run snapshots. + gateway: GatewayBinding, /// The shared bus handles session lifecycles report through. host: SessionHost, /// The running sessions by id. @@ -168,14 +168,14 @@ impl AgentSessions { pub(crate) fn new( agents_dir: PathBuf, sessions_dir: PathBuf, - client: Option, + gateway: GatewayBinding, host: SessionHost, ) -> Self { Self { inner: Arc::new(Inner { agents_dir, sessions_dir, - client, + gateway, host, sessions: Mutex::new(HashMap::new()), }), @@ -219,9 +219,9 @@ impl AgentSessions { // chat, but an agent run would fail its first model round - or // silently resolve a different gateway from the environment - so // the launch refuses instead. - let Some(client) = self.inner.client.clone() else { + if self.inner.gateway.snapshot().model_client().is_none() { return Err(LaunchRefusal::GatewayUnusable); - }; + } let source = agent_source(&self.inner.agents_dir, name) .map_err(|source| LaunchRefusal::SessionState { source })?; std::fs::create_dir_all(&self.inner.sessions_dir) @@ -232,6 +232,8 @@ impl AgentSessions { WorkshopObserver::new(Some(&log_path)) .map_err(|source| LaunchRefusal::SessionState { source })?, ); + let (supervisor_events, events) = mpsc::unbounded_channel(); + let lifecycle = Arc::new(RunLifecycle::new(supervisor_events)); let waits = Arc::new(WaitRegistry::new()); let (input_frames, _) = broadcast::channel(INPUT_CAPACITY); let (deltas, _) = broadcast::channel(DELTA_CAPACITY); @@ -241,20 +243,20 @@ impl AgentSessions { agent: name.to_owned(), source, log: Arc::clone(&observer), + lifecycle, rounds: Arc::new(AtomicU64::new(0)), waits, input_frames, deltas, errors, - cancel: Mutex::new(CancelHandle::new()), - closing: AtomicBool::new(false), }); self.lock().insert(id, Arc::clone(&session)); - spawn_supervisor( + supervisor::spawn( Arc::clone(&session), self.clone(), self.inner.host.clone(), - client, + self.inner.gateway.clone(), + events, ); Ok(session) } @@ -285,6 +287,19 @@ impl AgentSessions { Some(self.get(id)?.waits.unresolved()) } + /// Delivers a fixture response after running `after_acceptance` + /// between its durable observation and the waiting tool's resumption. + #[cfg(feature = "test-fixtures")] + pub fn deliver_input_after_acceptance_for_test( + &self, + id: &str, + response: InputResponse, + after_acceptance: impl FnOnce(), + ) -> Option> { + let session = self.get(id)?; + Some(session.accept_input(response, after_acceptance)) + } + /// The session map guard; a lock poisoned by a panicking peer /// recovers the value rather than wedging the process (zone two). fn lock(&self) -> MutexGuard<'_, HashMap>> { @@ -339,6 +354,8 @@ pub(crate) struct AgentSession { /// The persisting event log: `Observer` write side, `EventLog` read /// side, broadcast fan-out for socket wakeups. pub(crate) log: Arc, + /// Cancellation provenance and the accepted-turn exclusion boundary. + lifecycle: Arc, /// Settled model rounds - the reply id deltas are stamped with. rounds: Arc, /// The session's unresolved user-input waits. @@ -353,12 +370,6 @@ pub(crate) struct AgentSession { /// ended in error. Ephemeral like the deltas - errors never enter /// the event log. errors: broadcast::Sender, - /// The retained cancel handle of the current run, swapped fresh at - /// every (re)launch. - cancel: Mutex, - /// Set by [`close`](Self::close): the supervisor ends instead of - /// relaunching. - closing: AtomicBool, } impl fmt::Debug for AgentSession { @@ -382,36 +393,55 @@ impl AgentSession { self.errors.subscribe() } + /// Durably accepts one input and resumes its wait after publishing + /// acceptance ahead of the observation-to-completion boundary. + pub(crate) fn accept_input( + &self, + response: InputResponse, + after_acceptance: impl FnOnce(), + ) -> Result<(), WaitError> { + let accepted_run = self.lifecycle.accept_input(); + let result = deliver_input_response_before_completion( + self.log.as_ref(), + &self.waits, + &self.id, + &self.agent, + response, + after_acceptance, + ); + if let (Err(_), Some(run)) = (&result, accepted_run) { + self.lifecycle.settle_turn(run); + } + result + } + /// Fires the current run's retained cancel handle: the turn dies as /// a stop reason (pending waits emit `input_cancelled`, no error /// frame), and the supervisor relaunches the program over the /// retained event log with a fresh handle. pub(crate) fn cancel_turn(&self) { - self.cancel_guard().cancel(); + self.lifecycle.operator_cancel(); } /// Ends the session: the run is cancelled and the supervisor stops /// relaunching. fn close(&self) { - self.closing.store(true, Ordering::SeqCst); - self.cancel_turn(); + self.lifecycle.close(); } /// Installs and retains the next run's fresh cancel handle. - fn arm_cancel(&self) -> CancelHandle { - let fresh = CancelHandle::new(); - *self.cancel_guard() = fresh.clone(); - // A close that raced the swap still wins: cancel the fresh handle - // at once so the new run cannot outlive the decision to end. - if self.closing.load(Ordering::SeqCst) { - fresh.cancel(); - } - fresh + fn arm_cancel(&self, run: RunId) -> CancelHandle { + self.lifecycle.arm(run) } - /// The cancel-slot guard; poison recovered per the zone-two policy. - fn cancel_guard(&self) -> MutexGuard<'_, CancelHandle> { - self.cancel.lock().unwrap_or_else(PoisonError::into_inner) + /// Cancels the run selected by a reducer effect. + fn cancel_current_run(&self) { + self.lifecycle.cancel_current(); + } + + /// Clears the lifecycle identity after a run ends. + fn finish_run(&self, run: RunId) { + self.lifecycle.finish(run); } } @@ -432,6 +462,8 @@ struct SessionObserver { backoff: ReconnectBackoff, /// Where a failed model round surfaces as a wire error frame. errors: broadcast::Sender, + /// Marks an accepted turn settled before catalog retirement proceeds. + lifecycle: Arc, } impl Observer for SessionObserver { @@ -442,6 +474,7 @@ impl Observer for SessionObserver { // the SPA. The observation carries no payload; the frame names // the boundary that failed. if matches!(event, Observation::ModelTurnFailed) { + self.lifecycle.settle_current_turn(); let message = format!("{event} in agent `{section}`"); let _ = self.errors.send(message.clone()); // The failed round never reaches on_assistant_reply, so this @@ -477,6 +510,7 @@ impl Observer for SessionObserver { model, metrics, ); + self.lifecycle.settle_current_turn(); self.rounds.fetch_add(1, Ordering::SeqCst); self.backoff.record_useful_work(); self.push.push_idle(); @@ -543,100 +577,6 @@ impl Observer for SessionObserver { } } -/// Spawns the session's supervisor: run the agent, relaunch after a -/// turn-cancel over the retained event log with a fresh handle, end the -/// session when the program returns, fails, or the session closes. -fn spawn_supervisor( - session: Arc, - registry: AgentSessions, - host: SessionHost, - client: ModelClient, -) { - tokio::spawn(async move { - // Per-session pieces that survive relaunches: the tool catalog - // (`user_input` plus the configured tools - none are configured - // yet), the model catalog snapshot, the run-scoped store, and - // the observer wrapper. The event log alone is the state of - // record; the store is scratch that persisting across relaunches - // cannot corrupt. - let tool: Arc = Arc::new(UserInputTool::new( - Arc::clone(&session.waits), - session.input_frames.clone(), - )); - let tools = match ToolCatalog::new(&[tool]) { - Ok(tools) => tools, - Err(error) => { - // Unreachable in practice: the catalog holds one tool - // with a fixed legal wire name. Refusing the session - // beats serving an agent that cannot ask for input. - tracing::error!(%error, session = %session.id, "agent tool catalog refused"); - registry.forget(&session.id); - return; - } - }; - let models = build_model_catalog(host.catalog.latest().map(|push| push.models)); - let store = StoreRef::memory(); - let observer: Arc = Arc::new(SessionObserver { - log: Arc::clone(&session.log), - rounds: Arc::clone(&session.rounds), - push: host.push.clone(), - backoff: host.backoff.clone(), - errors: session.errors.clone(), - }); - let on_delta = delta_stamp(&session, &host.push); - let ui = ui_provider(&host.menu, &host.workspace); - loop { - let config = AgentConfig { - name: session.agent.clone(), - execution: session.id.clone(), - observer: Arc::clone(&observer), - cancel: session.arm_cancel(), - event_log: Some(Arc::clone(&session.log) as _), - on_delta: Some(Arc::clone(&on_delta)), - ui: Some(Arc::clone(&ui)), - limits: AgentLimits::default(), - }; - // Always the workshop's own client: a launch without one was - // refused, so the environment fallback can never fire here. - let result = run_agent_with_client( - &session.source, - &tools, - &models, - &store, - config, - Some(client.clone()), - ) - .await; - match result { - // Cancellation is a stop reason, not an error: a - // turn-cancel relaunches the program over the retained - // event log; a closing session ends quietly. - Err(AgentError::Interrupted) => { - if session.closing.load(Ordering::SeqCst) { - break; - } - } - Ok(()) => break, - Err(error) => { - tracing::warn!( - %error, - session = %session.id, - agent = %session.agent, - "agent run failed" - ); - // The terminal failure reaches the SPA too: the run - // is gone, so no later frame can say what happened. - let _ = session.errors.send(error.to_string()); - host.push - .push_failure("Agent failed", error.to_string(), Activity::General); - break; - } - } - } - registry.forget(&session.id); - }); -} - /// Builds the delta stamp: the `on_delta` closure feeding the session's /// dedicated broadcast, each chunk stamped with the current round count - /// the id of the durable event that will supersede it - plus the @@ -760,23 +700,9 @@ fn fresh_session_id() -> String { /// `None` - logged here, and refused per launch as /// [`LaunchRefusal::GatewayUnusable`] - when the key is empty (the model /// client refuses blank credentials) or the URL does not parse. -pub(crate) fn model_client(base_url: &str, api_key: &str) -> Option { - let key = match SecretString::new(api_key) { - Ok(key) => key, - Err(error) => { - tracing::warn!(%error, "agent sessions disabled: gateway API key unusable"); - return None; - } - }; - let root = format!("{}/v1", base_url.trim_end_matches('/')); - let endpoint = match GatewayEndpoint::new(&root) { - Ok(endpoint) => endpoint, - Err(error) => { - tracing::warn!(%error, "agent sessions disabled: gateway URL unusable"); - return None; - } - }; - Some(ModelClient::new(endpoint, key)) +#[cfg(test)] +fn model_client(base_url: &str, api_key: &str) -> Option { + crate::gateway_binding::model_client(base_url, api_key) } /// Builds the session's model catalog from the retained gateway catalog: @@ -791,17 +717,13 @@ fn build_model_catalog(models: Option>) -> ModelCatalog { }; let mut descriptors: Vec = Vec::new(); for entry in &models { + if !is_chat_capable(entry) { + continue; + } let Some(id) = entry.get("id").and_then(serde_json::Value::as_str) else { tracing::warn!("catalog entry without an id skipped for the agent model catalog"); continue; }; - if entry - .get("kind") - .and_then(serde_json::Value::as_str) - .is_some_and(|kind| kind != "chat") - { - continue; - } let model_id = match ModelId::gateway(id) { Ok(model_id) => model_id, Err(error) => { @@ -1024,7 +946,8 @@ mod tests { let sessions = AgentSessions::new( dir.path().to_path_buf(), dir.path().join("sessions"), - None, + GatewayBinding::new("http://127.0.0.1:1", "") + .expect("the unusable model binding still builds its HTTP client"), SessionHost { push: Push::new( crate::status::StatusBus::new(), @@ -1059,12 +982,14 @@ mod tests { let catalog = CatalogBus::new(); let menu = MenuBus::new(catalog.clone(), None); let (errors, mut errors_rx) = broadcast::channel(ERROR_CAPACITY); + let (supervisor_events, _events) = mpsc::unbounded_channel(); let observer = SessionObserver { log: Arc::new(WorkshopObserver::new(None).expect("a memory log")), rounds: Arc::new(AtomicU64::new(0)), push: Push::new(status, catalog, menu), backoff: ReconnectBackoff::new(), errors, + lifecycle: Arc::new(RunLifecycle::new(supervisor_events)), }; observer.observe("run", "chat", Observation::ModelTurnFailed); diff --git a/crates/workshop-server/src/session_agents/lifecycle.rs b/crates/workshop-server/src/session_agents/lifecycle.rs new file mode 100644 index 00000000..7d72882c --- /dev/null +++ b/crates/workshop-server/src/session_agents/lifecycle.rs @@ -0,0 +1,94 @@ +//! Supervisor event publication and current-run cancellation. + +use std::sync::{Mutex, MutexGuard, PoisonError}; + +use promptforge_core_support::cancel::CancelHandle; +use tokio::sync::mpsc; + +use super::supervisor::transition::{RunId, SupervisorEvent}; + +/// Synchronous producers for one supervisor's typed event stream. +pub(super) struct RunLifecycle { + state: Mutex, + events: mpsc::UnboundedSender, +} + +/// The current run identity and cancellation handle. +struct RunState { + cancel: CancelHandle, + run: Option, +} + +impl RunLifecycle { + /// Creates the lifecycle over the supervisor's event sender. + pub(super) fn new(events: mpsc::UnboundedSender) -> Self { + Self { + state: Mutex::new(RunState { + cancel: CancelHandle::new(), + run: None, + }), + events, + } + } + + /// Locks lifecycle state, recovering from a panicking peer. + fn lock(&self) -> MutexGuard<'_, RunState> { + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Arms the cancellation handle for `run`. + pub(super) fn arm(&self, run: RunId) -> CancelHandle { + let fresh = CancelHandle::new(); + let mut state = self.lock(); + state.cancel = fresh.clone(); + state.run = Some(run); + fresh + } + + /// Publishes an operator cancellation for reducer ownership. + pub(super) fn operator_cancel(&self) { + self.send(SupervisorEvent::OperatorCancellation); + } + + /// Publishes that input resumed the currently armed run. + pub(super) fn accept_input(&self) -> Option { + let run = self.lock().run?; + self.send(SupervisorEvent::AcceptedInput(run)); + Some(run) + } + + /// Publishes a durable terminal event for the currently armed run. + pub(super) fn settle_current_turn(&self) { + if let Some(run) = self.lock().run { + self.settle_turn(run); + } + } + + /// Publishes a terminal event scoped to `run`. + pub(super) fn settle_turn(&self, run: RunId) { + self.send(SupervisorEvent::TerminalSettlement(run)); + } + + /// Cancels the reducer-owned current run. + pub(super) fn cancel_current(&self) { + self.lock().cancel.cancel(); + } + + /// Clears `run` after its future completes or is dropped. + pub(super) fn finish(&self, run: RunId) { + let mut state = self.lock(); + if state.run == Some(run) { + state.run = None; + } + } + + /// Publishes session close for reducer ownership. + pub(super) fn close(&self) { + self.send(SupervisorEvent::Close); + } + + /// Sends one event; a gone receiver means supervision already ended. + fn send(&self, event: SupervisorEvent) { + let _ = self.events.send(event); + } +} diff --git a/crates/workshop-server/src/session_agents/socket.rs b/crates/workshop-server/src/session_agents/socket.rs index fb901c46..f7df7191 100644 --- a/crates/workshop-server/src/session_agents/socket.rs +++ b/crates/workshop-server/src/session_agents/socket.rs @@ -37,7 +37,7 @@ use tokio::sync::broadcast; use crate::app::AppState; use crate::cross_site; use crate::error::AppError; -use crate::input::{WaitError, deliver_input_response}; +use crate::input::WaitError; use crate::protocol::{ Activity, AgentDeltaFrame, AgentEventFrame, AgentSessionFrame, AgentsFrame, ErrorFrame, InputFrame, InputResponse, @@ -256,13 +256,7 @@ async fn handle_frame( } }; let session = &attached.session; - match deliver_input_response( - session.log.as_ref(), - &session.waits, - &session.id, - &session.agent, - response, - ) { + match session.accept_input(response, || {}) { // The wait completed: the turn is dispatched. Ok(()) => state.push().push_status_update( "Running agent turn", diff --git a/crates/workshop-server/src/session_agents/supervisor.rs b/crates/workshop-server/src/session_agents/supervisor.rs new file mode 100644 index 00000000..eb187a74 --- /dev/null +++ b/crates/workshop-server/src/session_agents/supervisor.rs @@ -0,0 +1,71 @@ +//! Agent-run supervision across cancellation and catalog generations. + +use std::sync::Arc; + +use promptforge_tools::{Tool, ToolCatalog}; +use tokio::sync::mpsc; + +use crate::gateway_binding::GatewayBinding; +use crate::input::UserInputTool; + +use super::{AgentSession, AgentSessions, SessionHost}; +mod catalog; +mod effects; +mod events; +pub(super) mod transition; +use effects::{EffectExecutor, EffectOutcome}; +use events::{CollectedEvent, EventCollector}; +use transition::{SupervisorEvent, SupervisorState, transition}; + +/// Spawns one session supervisor. Each run freezes one usable chat +/// catalog; cancellation or a genuinely new usable generation relaunches +/// over the retained event log. +pub(super) fn spawn( + session: Arc, + registry: AgentSessions, + host: SessionHost, + gateway: GatewayBinding, + lifecycle: mpsc::UnboundedReceiver, +) { + tokio::spawn(async move { + let tool: Arc = Arc::new(UserInputTool::new( + Arc::clone(&session.waits), + session.input_frames.clone(), + )); + let tools = match ToolCatalog::new(&[tool]) { + Ok(tools) => tools, + Err(error) => { + tracing::error!(%error, session = %session.id, "agent tool catalog refused"); + registry.forget(&session.id); + return; + } + }; + let (mut collector, initial_catalog, initial_gateway) = + EventCollector::new(lifecycle, host.catalog.clone(), gateway); + let mut executor = EffectExecutor::new( + Arc::clone(&session), + host, + tools, + initial_catalog.snapshot, + Arc::clone(&initial_gateway), + ); + let mut state = SupervisorState::new(initial_gateway.generation()); + let mut pending_event = Some(initial_catalog.event); + + loop { + let collected = match pending_event.take() { + Some(event) => CollectedEvent::Supervisor(event), + None => executor.next_event(&mut collector).await, + }; + let event = executor.event_from(collected); + let next = transition(state, event); + state = next.state; + match executor.execute(next.effect) { + EffectOutcome::Continue => {} + EffectOutcome::Event(event) => pending_event = Some(event), + EffectOutcome::Close => break, + } + } + registry.forget(&session.id); + }); +} diff --git a/crates/workshop-server/src/session_agents/supervisor/catalog.rs b/crates/workshop-server/src/session_agents/supervisor/catalog.rs new file mode 100644 index 00000000..0c5c1ba9 --- /dev/null +++ b/crates/workshop-server/src/session_agents/supervisor/catalog.rs @@ -0,0 +1,56 @@ +//! Typed catalog-event collection for one agent supervisor. + +use crate::catalog::{CatalogBus, ChatCatalog}; + +use super::transition::{CatalogDisposition, SupervisorEvent}; + +/// One collected event and the catalog snapshot that produced it. +pub(super) struct CatalogEvent { + pub(super) event: SupervisorEvent, + pub(super) snapshot: Option, +} + +/// Collects the receiver's current catalog generation without waiting. +pub(super) fn current_catalog_event( + catalog: &CatalogBus, + generation: &mut tokio::sync::watch::Receiver, +) -> CatalogEvent { + let observed = *generation.borrow_and_update(); + classify(catalog.latest_chat(), observed, None) +} + +/// Waits for and classifies the next catalog generation. +pub(super) async fn next_catalog_event( + catalog: &CatalogBus, + generation: &mut tokio::sync::watch::Receiver, + active_models: Option<&[serde_json::Value]>, +) -> CatalogEvent { + if generation.changed().await.is_err() { + std::future::pending::<()>().await; + } + let observed = *generation.borrow_and_update(); + classify(catalog.latest_chat(), observed, active_models) +} + +/// Classifies one retained snapshot against the run's frozen bindings. +fn classify( + snapshot: Option, + observed_generation: u64, + active_models: Option<&[serde_json::Value]>, +) -> CatalogEvent { + let generation = snapshot + .as_ref() + .map_or(observed_generation, |chat| chat.generation); + let disposition = match (&snapshot, active_models) { + (None, _) => CatalogDisposition::Unavailable, + (Some(chat), Some(active)) if chat.models != active => CatalogDisposition::Replacement, + (Some(_), _) => CatalogDisposition::Retained, + }; + CatalogEvent { + event: SupervisorEvent::CatalogGeneration { + generation, + disposition, + }, + snapshot, + } +} diff --git a/crates/workshop-server/src/session_agents/supervisor/effects.rs b/crates/workshop-server/src/session_agents/supervisor/effects.rs new file mode 100644 index 00000000..2bbe98e0 --- /dev/null +++ b/crates/workshop-server/src/session_agents/supervisor/effects.rs @@ -0,0 +1,286 @@ +//! Execution of reducer-selected supervisor effects. + +use std::sync::Arc; + +use promptforge_agent::{AgentConfig, AgentError, AgentLimits, run_agent_with_client}; +use promptforge_core_support::observe::Observer; +use promptforge_model_client::client::{GatewayClient as ModelClient, StreamDelta}; +use promptforge_store::StoreRef; +use promptforge_tools::ToolCatalog; + +use crate::catalog::ChatCatalog; +use crate::gateway_binding::GatewaySnapshot; +use crate::protocol::Activity; + +use super::events::{CollectedEvent, EventCollector, RunFuture}; +use super::transition::{ + CancelOrigin, CatalogDisposition, CloseReason, HistoryEffect, RelaunchEffect, RunCompletion, + RunId, SupervisorEffect, SupervisorEvent, +}; +use crate::session_agents::{ + AgentSession, SessionHost, SessionObserver, build_model_catalog, delta_stamp, ui_provider, +}; + +/// The result of executing one reducer-selected effect. +pub(super) enum EffectOutcome { + Continue, + Event(SupervisorEvent), + Close, +} + +/// Immutable resources reused by each reducer-selected relaunch. +struct RunFactory { + session: Arc, + tools: ToolCatalog, + store: StoreRef, + observer: Arc, + on_delta: Arc, + ui: Arc serde_json::Value + Send + Sync>, +} + +impl RunFactory { + /// Builds reusable run resources for one session. + fn new(session: Arc, tools: ToolCatalog, host: &SessionHost) -> Self { + let observer: Arc = Arc::new(SessionObserver { + log: Arc::clone(&session.log), + rounds: Arc::clone(&session.rounds), + push: host.push.clone(), + backoff: host.backoff.clone(), + errors: session.errors.clone(), + lifecycle: Arc::clone(&session.lifecycle), + }); + Self { + on_delta: delta_stamp(&session, &host.push), + ui: ui_provider(&host.menu, &host.workspace), + session, + tools, + store: StoreRef::memory(), + observer, + } + } + + /// Builds one run over retained history and frozen bindings. + fn launch(&self, run: RunId, models: Vec, client: ModelClient) -> RunFuture { + let source = self.session.source.clone(); + let tools = self.tools.clone(); + let models = build_model_catalog(Some(models)); + let store = self.store.clone(); + let config = AgentConfig { + name: self.session.agent.clone(), + execution: self.session.id.clone(), + observer: Arc::clone(&self.observer), + cancel: self.session.arm_cancel(run), + event_log: Some(Arc::clone(&self.session.log) as _), + on_delta: Some(Arc::clone(&self.on_delta)), + ui: Some(Arc::clone(&self.ui)), + limits: AgentLimits::default(), + }; + Box::pin(async move { + let result = + run_agent_with_client(&source, &tools, &models, &store, config, Some(client)).await; + (run, result) + }) + } +} + +/// Mutable runtime bindings and the currently executing run. +pub(super) struct EffectExecutor { + session: Arc, + host: SessionHost, + factory: RunFactory, + latest_catalog: Option, + active_catalog: Option, + latest_gateway: Arc, + active_gateway: Option>, + active_run: Option, +} + +impl EffectExecutor { + /// Creates the executor from snapshots collected after subscriptions. + pub(super) fn new( + session: Arc, + host: SessionHost, + tools: ToolCatalog, + initial_catalog: Option, + initial_gateway: Arc, + ) -> Self { + Self { + factory: RunFactory::new(Arc::clone(&session), tools, &host), + session, + host, + latest_catalog: initial_catalog, + active_catalog: None, + latest_gateway: initial_gateway, + active_gateway: None, + active_run: None, + } + } + + /// Collects the next event using the currently frozen run bindings. + pub(super) async fn next_event(&mut self, collector: &mut EventCollector) -> CollectedEvent { + let active_models = self + .active_catalog + .as_ref() + .map(|catalog| catalog.models.as_slice()); + collector + .next(active_models, self.active_run.as_mut()) + .await + } + + /// Applies collected runtime data and returns only the pure event. + pub(super) fn event_from(&mut self, collected: CollectedEvent) -> SupervisorEvent { + match collected { + CollectedEvent::Supervisor(event) => event, + CollectedEvent::Catalog(catalog) => { + let event = catalog.event; + if matches!( + event, + SupervisorEvent::CatalogGeneration { + disposition: CatalogDisposition::Retained, + .. + } + ) && self.active_catalog.is_some() + { + self.active_catalog.clone_from(&catalog.snapshot); + } + self.latest_catalog = catalog.snapshot; + event + } + CollectedEvent::Gateway { event, snapshot } => { + self.latest_gateway = snapshot; + event + } + CollectedEvent::Run { run, result } => { + self.active_run.take(); + self.session.finish_run(run); + run_completion_event(run, result, &self.session, &self.host) + } + } + } + + /// Executes one typed effect without making transition decisions. + pub(super) fn execute(&mut self, effect: SupervisorEffect) -> EffectOutcome { + match effect { + SupervisorEffect::Wait(_) | SupervisorEffect::Preserve(_) => EffectOutcome::Continue, + SupervisorEffect::Cancel(origin) => { + report_cancel_origin(&self.session, origin); + self.session.cancel_current_run(); + EffectOutcome::Continue + } + SupervisorEffect::Relaunch(relaunch) => self.relaunch(relaunch), + SupervisorEffect::Close(reason) => { + if reason == CloseReason::Requested { + self.session.cancel_current_run(); + } + self.active_run.take(); + EffectOutcome::Close + } + } + } + + /// Resolves and launches one reducer-selected binding generation. + fn relaunch(&mut self, relaunch: RelaunchEffect) -> EffectOutcome { + let catalog = binding_for_catalog(relaunch, self.latest_catalog.as_ref()).cloned(); + let gateway = + binding_for_gateway(relaunch, &self.latest_gateway, self.active_gateway.as_ref()) + .cloned(); + let (Some(catalog), Some(gateway)) = (catalog, gateway) else { + report_failure( + &self.session, + &self.host, + "agent supervisor lost a reducer-selected binding", + ); + return failed_relaunch(relaunch.run); + }; + let Some(client) = gateway.model_client() else { + report_failure( + &self.session, + &self.host, + "the replacement Gateway credentials cannot make a model client", + ); + return failed_relaunch(relaunch.run); + }; + match relaunch.history { + HistoryEffect::Preserve => {} + } + self.active_catalog = Some(catalog.clone()); + self.active_gateway = Some(gateway); + self.active_run = Some(self.factory.launch(relaunch.run, catalog.models, client)); + EffectOutcome::Continue + } +} + +/// Converts one run result into its typed reducer event. +fn run_completion_event( + run: RunId, + result: Result<(), AgentError>, + session: &AgentSession, + host: &SessionHost, +) -> SupervisorEvent { + let result = match result { + Err(AgentError::Interrupted) => RunCompletion::Interrupted, + Ok(()) => RunCompletion::Completed, + Err(error) => { + tracing::warn!( + %error, + session = %session.id, + agent = %session.agent, + "agent run failed" + ); + let _ = session.errors.send(error.to_string()); + host.push + .push_failure("Agent failed", error.to_string(), Activity::General); + RunCompletion::Failed + } + }; + SupervisorEvent::RunCompleted { run, result } +} + +/// Records reducer-selected retirement separately from operator cancellation. +fn report_cancel_origin(session: &AgentSession, origin: CancelOrigin) { + match origin { + CancelOrigin::Operator => {} + CancelOrigin::Catalog => tracing::debug!( + session = %session.id, + "agent run retired for a new catalog generation" + ), + CancelOrigin::Gateway => tracing::debug!( + session = %session.id, + "agent run retired for a new gateway generation" + ), + } +} + +/// Reports a failure shared by relaunch validation paths. +fn report_failure(session: &AgentSession, host: &SessionHost, message: &str) { + let _ = session.errors.send(message.to_owned()); + host.push + .push_failure("Agent failed", message, Activity::General); +} + +/// Converts a failed relaunch into the reducer's terminal event. +fn failed_relaunch(run: RunId) -> EffectOutcome { + EffectOutcome::Event(SupervisorEvent::RunCompleted { + run, + result: RunCompletion::Failed, + }) +} + +/// Resolves a reducer-selected catalog generation from retained bindings. +fn binding_for_catalog( + effect: RelaunchEffect, + latest: Option<&ChatCatalog>, +) -> Option<&ChatCatalog> { + latest.filter(|catalog| catalog.generation == effect.catalog_generation) +} + +/// Resolves a reducer-selected Gateway generation from retained bindings. +fn binding_for_gateway<'a>( + effect: RelaunchEffect, + latest: &'a Arc, + active: Option<&'a Arc>, +) -> Option<&'a Arc> { + (latest.generation() == effect.gateway_generation) + .then_some(latest) + .or_else(|| active.filter(|gateway| gateway.generation() == effect.gateway_generation)) +} diff --git a/crates/workshop-server/src/session_agents/supervisor/events.rs b/crates/workshop-server/src/session_agents/supervisor/events.rs new file mode 100644 index 00000000..e5d1261c --- /dev/null +++ b/crates/workshop-server/src/session_agents/supervisor/events.rs @@ -0,0 +1,137 @@ +//! Typed asynchronous event collection for one supervisor. + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use promptforge_agent::AgentError; +use tokio::sync::{mpsc, watch}; + +use crate::catalog::CatalogBus; +use crate::gateway_binding::{GatewayBinding, GatewaySnapshot}; + +use super::catalog::{CatalogEvent, current_catalog_event, next_catalog_event}; +use super::transition::{RunId, SupervisorEvent}; + +/// One owned run future paired with its reducer identity. +pub(super) type RunFuture = Pin)> + Send>>; + +/// Runtime data collected alongside one pure supervisor event. +pub(super) enum CollectedEvent { + Supervisor(SupervisorEvent), + Catalog(CatalogEvent), + Gateway { + event: SupervisorEvent, + snapshot: Arc, + }, + Run { + run: RunId, + result: Result<(), AgentError>, + }, +} + +/// External event sources owned by one supervisor. +pub(super) struct EventCollector { + lifecycle: mpsc::UnboundedReceiver, + catalog: CatalogBus, + catalog_generation: watch::Receiver, + gateway: GatewayBinding, + gateway_generation: watch::Receiver, +} + +impl EventCollector { + /// Subscribes before loading initial snapshots so replacements cannot + /// disappear between those operations. + pub(super) fn new( + lifecycle: mpsc::UnboundedReceiver, + catalog: CatalogBus, + gateway: GatewayBinding, + ) -> (Self, CatalogEvent, Arc) { + let mut catalog_generation = catalog.subscribe_chat_generation(); + let gateway_generation = gateway.subscribe(); + let initial_catalog = current_catalog_event(&catalog, &mut catalog_generation); + let initial_gateway = gateway.snapshot(); + ( + Self { + lifecycle, + catalog, + catalog_generation, + gateway, + gateway_generation, + }, + initial_catalog, + initial_gateway, + ) + } + + /// Waits for the next typed event, prioritizing synchronous lifecycle + /// events that causally precede a run wake or watched replacement. + pub(super) async fn next( + &mut self, + active_models: Option<&[serde_json::Value]>, + active_run: Option<&mut RunFuture>, + ) -> CollectedEvent { + if let Some(run) = active_run { + tokio::select! { + biased; + event = next_lifecycle_event(&mut self.lifecycle) => { + CollectedEvent::Supervisor(event) + } + catalog = next_catalog_event( + &self.catalog, + &mut self.catalog_generation, + active_models, + ) => CollectedEvent::Catalog(catalog), + gateway = next_gateway_event( + &self.gateway, + &mut self.gateway_generation, + ) => gateway, + result = run.as_mut() => { + let (run, result) = result; + CollectedEvent::Run { run, result } + } + } + } else { + tokio::select! { + biased; + event = next_lifecycle_event(&mut self.lifecycle) => { + CollectedEvent::Supervisor(event) + } + catalog = next_catalog_event( + &self.catalog, + &mut self.catalog_generation, + active_models, + ) => CollectedEvent::Catalog(catalog), + gateway = next_gateway_event( + &self.gateway, + &mut self.gateway_generation, + ) => gateway, + } + } + } +} + +/// Waits for the host's next complete Gateway snapshot. +async fn next_gateway_event( + gateway: &GatewayBinding, + generation: &mut watch::Receiver, +) -> CollectedEvent { + if generation.changed().await.is_err() { + std::future::pending::<()>().await; + } + let snapshot = gateway.snapshot(); + CollectedEvent::Gateway { + event: SupervisorEvent::GatewayGeneration(snapshot.generation()), + snapshot, + } +} + +/// Waits for the next synchronous lifecycle event. +async fn next_lifecycle_event( + lifecycle: &mut mpsc::UnboundedReceiver, +) -> SupervisorEvent { + match lifecycle.recv().await { + Some(event) => event, + None => std::future::pending().await, + } +} diff --git a/crates/workshop-server/src/session_agents/supervisor/transition.rs b/crates/workshop-server/src/session_agents/supervisor/transition.rs new file mode 100644 index 00000000..02697812 --- /dev/null +++ b/crates/workshop-server/src/session_agents/supervisor/transition.rs @@ -0,0 +1,405 @@ +//! Pure state transitions for one agent-session supervisor. + +/// Why the current run's cancellation handle fires. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::session_agents) enum CancelOrigin { + /// The operator explicitly cancelled the current turn. + Operator, + /// A usable catalog generation replaced the run's frozen bindings. + Catalog, + /// The desktop host published a new Gateway generation. + Gateway, +} + +/// One run's terminal result. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::session_agents) enum RunCompletion { + /// Cancellation stopped the run without ending the session. + Interrupted, + /// The program returned normally. + Completed, + /// The program failed. + Failed, +} + +/// How a published catalog generation relates to the frozen run catalog. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::session_agents) enum CatalogDisposition { + /// No chat-capable catalog is currently available. + Unavailable, + /// The generation is usable without changing frozen model bindings. + Retained, + /// The generation is usable and changes frozen model bindings. + Replacement, +} + +/// Identity assigned to one launched run. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::session_agents) struct RunId(u64); + +/// An input to the pure supervisor transition model. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::session_agents) enum SupervisorEvent { + /// A run produced its terminal result. + RunCompleted { + /// The run that completed. + run: RunId, + /// How it completed. + result: RunCompletion, + }, + /// The host published a catalog generation. + CatalogGeneration { + /// The catalog bus generation. + generation: u64, + /// Whether the frozen run can retain its bindings. + disposition: CatalogDisposition, + }, + /// The atomically published Gateway generation changed. + GatewayGeneration(u64), + /// The operator cancelled the current turn. + OperatorCancellation, + /// A durable input event resumed this run. + AcceptedInput(RunId), + /// The accepted turn reached a durable terminal event. + TerminalSettlement(RunId), + /// The owning session closed. + Close, +} + +/// The condition the supervisor must await. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::session_agents) enum WaitFor { + /// A usable chat catalog. + Catalog, + /// The accepted turn's durable terminal event. + TerminalSettlement, +} + +/// Why the current ownership remains unchanged. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::session_agents) enum PreserveReason { + /// The current run remains authoritative. + CurrentRun, + /// Cancellation already owns run retirement. + CancellationPending, + /// A duplicate or stale event has already been accounted for. + AlreadyHandled, + /// The session is already closed. + Closed, +} + +/// Event-log handling for a launched replacement run. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::session_agents) enum HistoryEffect { + /// Reuse the session's retained event log. + Preserve, +} + +/// The complete immutable inputs for one replacement run. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::session_agents) struct RelaunchEffect { + /// Identity assigned to the replacement run. + pub(in crate::session_agents) run: RunId, + /// Catalog generation frozen by the replacement. + pub(in crate::session_agents) catalog_generation: u64, + /// Gateway generation frozen by the replacement. + pub(in crate::session_agents) gateway_generation: u64, + /// Event-log treatment across replacement. + pub(in crate::session_agents) history: HistoryEffect, +} + +/// Why supervision ends. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::session_agents) enum CloseReason { + /// The owning session requested close. + Requested, + /// The agent program returned normally. + RunCompleted, + /// The agent program failed. + RunFailed, +} + +/// One typed action selected by the transition model. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::session_agents) enum SupervisorEffect { + /// Await a named condition. + Wait(WaitFor), + /// Cancel the current run with provenance. + Cancel(CancelOrigin), + /// Keep the named ownership unchanged. + Preserve(PreserveReason), + /// Launch a replacement over retained history. + Relaunch(RelaunchEffect), + /// End supervision. + Close(CloseReason), +} + +/// Whether the session is waiting, running, retiring, or closed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Phase { + WaitingForCatalog, + Running, + Cancelling, + Closed, +} + +/// Pure state owned by one agent-session supervisor. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::session_agents) struct SupervisorState { + phase: Phase, + active_run: Option, + next_run: u64, + catalog_generation: Option, + observed_catalog_generation: Option, + catalog_retirement_pending: bool, + gateway_generation: u64, + accepted_run: Option, +} + +impl SupervisorState { + /// Starts supervision before a usable chat catalog exists. + pub(in crate::session_agents) fn new(gateway_generation: u64) -> Self { + Self { + phase: Phase::WaitingForCatalog, + active_run: None, + next_run: 1, + catalog_generation: None, + observed_catalog_generation: None, + catalog_retirement_pending: false, + gateway_generation, + accepted_run: None, + } + } +} + +/// The next immutable state and its one typed effect. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::session_agents) struct SupervisorTransition { + pub(in crate::session_agents) state: SupervisorState, + pub(in crate::session_agents) effect: SupervisorEffect, +} + +/// Reduces one explicit event without performing asynchronous work. +pub(in crate::session_agents) fn transition( + state: SupervisorState, + event: SupervisorEvent, +) -> SupervisorTransition { + if state.phase == Phase::Closed { + return changed(state, SupervisorEffect::Preserve(PreserveReason::Closed)); + } + match event { + SupervisorEvent::Close => close(state, CloseReason::Requested), + SupervisorEvent::CatalogGeneration { + generation, + disposition, + } => catalog_changed(state, generation, disposition), + SupervisorEvent::GatewayGeneration(generation) => gateway_changed(state, generation), + SupervisorEvent::OperatorCancellation => operator_cancelled(state), + SupervisorEvent::AcceptedInput(run) => input_accepted(state, run), + SupervisorEvent::TerminalSettlement(run) => turn_settled(state, run), + SupervisorEvent::RunCompleted { run, result } => run_completed(state, run, result), + } +} + +fn catalog_changed( + mut state: SupervisorState, + generation: u64, + disposition: CatalogDisposition, +) -> SupervisorTransition { + if state + .observed_catalog_generation + .is_some_and(|observed| generation <= observed) + { + return changed( + state, + SupervisorEffect::Preserve(PreserveReason::AlreadyHandled), + ); + } + state.observed_catalog_generation = Some(generation); + state.catalog_generation = + (disposition != CatalogDisposition::Unavailable).then_some(generation); + + match state.phase { + Phase::WaitingForCatalog => { + if disposition == CatalogDisposition::Unavailable { + return changed(state, SupervisorEffect::Wait(WaitFor::Catalog)); + } + relaunch(state) + } + Phase::Running => match disposition { + CatalogDisposition::Unavailable | CatalogDisposition::Retained => { + let effect = + if state.catalog_retirement_pending && state.accepted_run == state.active_run { + SupervisorEffect::Wait(WaitFor::TerminalSettlement) + } else { + SupervisorEffect::Preserve(PreserveReason::CurrentRun) + }; + changed(state, effect) + } + CatalogDisposition::Replacement => { + state.catalog_retirement_pending = true; + if state.accepted_run == state.active_run { + changed(state, SupervisorEffect::Wait(WaitFor::TerminalSettlement)) + } else { + state.phase = Phase::Cancelling; + changed(state, SupervisorEffect::Cancel(CancelOrigin::Catalog)) + } + } + }, + Phase::Cancelling => changed( + state, + SupervisorEffect::Preserve(PreserveReason::CancellationPending), + ), + Phase::Closed => changed(state, SupervisorEffect::Preserve(PreserveReason::Closed)), + } +} + +fn gateway_changed(mut state: SupervisorState, generation: u64) -> SupervisorTransition { + if generation <= state.gateway_generation { + let reason = if state.phase == Phase::Cancelling { + PreserveReason::CancellationPending + } else { + PreserveReason::CurrentRun + }; + return changed(state, SupervisorEffect::Preserve(reason)); + } + state.gateway_generation = generation; + match state.phase { + Phase::WaitingForCatalog => changed(state, SupervisorEffect::Wait(WaitFor::Catalog)), + Phase::Running => { + state.accepted_run = None; + state.phase = Phase::Cancelling; + changed(state, SupervisorEffect::Cancel(CancelOrigin::Gateway)) + } + Phase::Cancelling => changed( + state, + SupervisorEffect::Preserve(PreserveReason::CancellationPending), + ), + Phase::Closed => changed(state, SupervisorEffect::Preserve(PreserveReason::Closed)), + } +} + +fn operator_cancelled(mut state: SupervisorState) -> SupervisorTransition { + match state.phase { + Phase::WaitingForCatalog => changed(state, SupervisorEffect::Wait(WaitFor::Catalog)), + Phase::Running => { + state.accepted_run = None; + state.phase = Phase::Cancelling; + changed(state, SupervisorEffect::Cancel(CancelOrigin::Operator)) + } + Phase::Cancelling => changed( + state, + SupervisorEffect::Preserve(PreserveReason::CancellationPending), + ), + Phase::Closed => changed(state, SupervisorEffect::Preserve(PreserveReason::Closed)), + } +} + +fn input_accepted(mut state: SupervisorState, run: RunId) -> SupervisorTransition { + if state.phase != Phase::Running || state.active_run != Some(run) { + return changed( + state, + SupervisorEffect::Preserve(PreserveReason::AlreadyHandled), + ); + } + if state.accepted_run == Some(run) { + return changed( + state, + SupervisorEffect::Preserve(PreserveReason::AlreadyHandled), + ); + } + state.accepted_run = Some(run); + changed( + state, + SupervisorEffect::Preserve(PreserveReason::CurrentRun), + ) +} + +fn turn_settled(mut state: SupervisorState, run: RunId) -> SupervisorTransition { + if state.active_run != Some(run) { + return changed( + state, + SupervisorEffect::Preserve(PreserveReason::AlreadyHandled), + ); + } + if state.phase == Phase::Cancelling { + return changed( + state, + SupervisorEffect::Preserve(PreserveReason::CancellationPending), + ); + } + if state.accepted_run != Some(run) { + return changed( + state, + SupervisorEffect::Preserve(PreserveReason::AlreadyHandled), + ); + } + state.accepted_run = None; + if state.catalog_retirement_pending { + state.phase = Phase::Cancelling; + changed(state, SupervisorEffect::Cancel(CancelOrigin::Catalog)) + } else { + changed( + state, + SupervisorEffect::Preserve(PreserveReason::CurrentRun), + ) + } +} + +fn run_completed( + mut state: SupervisorState, + run: RunId, + result: RunCompletion, +) -> SupervisorTransition { + if state.active_run != Some(run) { + return changed( + state, + SupervisorEffect::Preserve(PreserveReason::AlreadyHandled), + ); + } + state.active_run = None; + state.accepted_run = None; + match result { + RunCompletion::Interrupted => relaunch(state), + RunCompletion::Completed => close(state, CloseReason::RunCompleted), + RunCompletion::Failed => close(state, CloseReason::RunFailed), + } +} + +fn relaunch(mut state: SupervisorState) -> SupervisorTransition { + let Some(catalog_generation) = state.catalog_generation else { + state.phase = Phase::WaitingForCatalog; + state.catalog_retirement_pending = false; + return changed(state, SupervisorEffect::Wait(WaitFor::Catalog)); + }; + let run = RunId(state.next_run); + state.next_run = state.next_run.saturating_add(1); + state.catalog_generation = Some(catalog_generation); + state.active_run = Some(run); + state.accepted_run = None; + state.phase = Phase::Running; + state.catalog_retirement_pending = false; + let effect = RelaunchEffect { + run, + catalog_generation, + gateway_generation: state.gateway_generation, + history: HistoryEffect::Preserve, + }; + changed(state, SupervisorEffect::Relaunch(effect)) +} + +fn close(mut state: SupervisorState, reason: CloseReason) -> SupervisorTransition { + state.phase = Phase::Closed; + state.active_run = None; + state.accepted_run = None; + state.catalog_generation = None; + state.catalog_retirement_pending = false; + changed(state, SupervisorEffect::Close(reason)) +} + +fn changed(state: SupervisorState, effect: SupervisorEffect) -> SupervisorTransition { + SupervisorTransition { state, effect } +} + +#[cfg(test)] +mod tests; diff --git a/crates/workshop-server/src/session_agents/supervisor/transition/tests.rs b/crates/workshop-server/src/session_agents/supervisor/transition/tests.rs new file mode 100644 index 00000000..f31f3b79 --- /dev/null +++ b/crates/workshop-server/src/session_agents/supervisor/transition/tests.rs @@ -0,0 +1,344 @@ +use super::*; + +const RUN_1: RunId = RunId(1); +const RUN_2: RunId = RunId(2); + +fn catalog(generation: u64, disposition: CatalogDisposition) -> SupervisorEvent { + SupervisorEvent::CatalogGeneration { + generation, + disposition, + } +} + +fn completed(run: RunId, result: RunCompletion) -> SupervisorEvent { + SupervisorEvent::RunCompleted { run, result } +} + +fn relaunch(run: RunId, catalog: u64, gateway: u64) -> SupervisorEffect { + SupervisorEffect::Relaunch(RelaunchEffect { + run, + catalog_generation: catalog, + gateway_generation: gateway, + history: HistoryEffect::Preserve, + }) +} + +fn apply(gateway: u64, events: &[SupervisorEvent]) -> (SupervisorState, Vec) { + let mut state = SupervisorState::new(gateway); + let effects = events + .iter() + .map(|event| { + let next = transition(state, *event); + state = next.state; + next.effect + }) + .collect(); + (state, effects) +} + +struct Scenario { + name: &'static str, + events: Vec, + effects: Vec, + phase: Phase, +} + +fn assert_scenarios(scenarios: Vec) { + for scenario in scenarios { + let (state, effects) = apply(7, &scenario.events); + assert_eq!(effects, scenario.effects, "{}", scenario.name); + assert_eq!(state.phase, scenario.phase, "{}", scenario.name); + } +} + +#[test] +fn transition_table_covers_wait_cancel_and_relaunch_effects() { + assert_scenarios(vec![ + Scenario { + name: "delayed catalog follows the latest gateway", + events: vec![ + catalog(1, CatalogDisposition::Unavailable), + SupervisorEvent::GatewayGeneration(8), + catalog(2, CatalogDisposition::Retained), + ], + effects: vec![ + SupervisorEffect::Wait(WaitFor::Catalog), + SupervisorEffect::Wait(WaitFor::Catalog), + relaunch(RUN_1, 2, 8), + ], + phase: Phase::Running, + }, + Scenario { + name: "overlapping catalog retirement relaunches the newest applicable generation", + events: vec![ + catalog(1, CatalogDisposition::Retained), + catalog(2, CatalogDisposition::Replacement), + catalog(3, CatalogDisposition::Retained), + completed(RUN_1, RunCompletion::Interrupted), + ], + effects: vec![ + relaunch(RUN_1, 1, 7), + SupervisorEffect::Cancel(CancelOrigin::Catalog), + SupervisorEffect::Preserve(PreserveReason::CancellationPending), + relaunch(RUN_2, 3, 7), + ], + phase: Phase::Running, + }, + Scenario { + name: "accepted input retires but unavailable catalog cannot relaunch", + events: vec![ + catalog(1, CatalogDisposition::Retained), + SupervisorEvent::AcceptedInput(RUN_1), + catalog(2, CatalogDisposition::Replacement), + catalog(3, CatalogDisposition::Unavailable), + SupervisorEvent::TerminalSettlement(RUN_1), + completed(RUN_1, RunCompletion::Interrupted), + catalog(4, CatalogDisposition::Retained), + ], + effects: vec![ + relaunch(RUN_1, 1, 7), + SupervisorEffect::Preserve(PreserveReason::CurrentRun), + SupervisorEffect::Wait(WaitFor::TerminalSettlement), + SupervisorEffect::Wait(WaitFor::TerminalSettlement), + SupervisorEffect::Cancel(CancelOrigin::Catalog), + SupervisorEffect::Wait(WaitFor::Catalog), + relaunch(RUN_2, 4, 7), + ], + phase: Phase::Running, + }, + ]); +} + +#[test] +fn transition_table_covers_preservation_and_immediate_retirement() { + assert_scenarios(vec![ + Scenario { + name: "unavailable and retained catalogs preserve a running generation", + events: vec![ + catalog(1, CatalogDisposition::Retained), + catalog(2, CatalogDisposition::Unavailable), + catalog(3, CatalogDisposition::Retained), + SupervisorEvent::TerminalSettlement(RUN_1), + ], + effects: vec![ + relaunch(RUN_1, 1, 7), + SupervisorEffect::Preserve(PreserveReason::CurrentRun), + SupervisorEffect::Preserve(PreserveReason::CurrentRun), + SupervisorEffect::Preserve(PreserveReason::AlreadyHandled), + ], + phase: Phase::Running, + }, + Scenario { + name: "gateway replacement coalesces the latest retained catalog", + events: vec![ + catalog(1, CatalogDisposition::Retained), + SupervisorEvent::AcceptedInput(RUN_1), + catalog(2, CatalogDisposition::Replacement), + catalog(3, CatalogDisposition::Retained), + SupervisorEvent::GatewayGeneration(8), + SupervisorEvent::TerminalSettlement(RUN_1), + completed(RUN_1, RunCompletion::Interrupted), + ], + effects: vec![ + relaunch(RUN_1, 1, 7), + SupervisorEffect::Preserve(PreserveReason::CurrentRun), + SupervisorEffect::Wait(WaitFor::TerminalSettlement), + SupervisorEffect::Wait(WaitFor::TerminalSettlement), + SupervisorEffect::Cancel(CancelOrigin::Gateway), + SupervisorEffect::Preserve(PreserveReason::CancellationPending), + relaunch(RUN_2, 3, 8), + ], + phase: Phase::Running, + }, + Scenario { + name: "operator cancellation interrupts and relaunches", + events: vec![ + catalog(1, CatalogDisposition::Retained), + SupervisorEvent::OperatorCancellation, + completed(RUN_1, RunCompletion::Interrupted), + ], + effects: vec![ + relaunch(RUN_1, 1, 7), + SupervisorEffect::Cancel(CancelOrigin::Operator), + relaunch(RUN_2, 1, 7), + ], + phase: Phase::Running, + }, + ]); +} + +#[test] +fn transition_table_covers_terminal_close_and_stale_events() { + assert_scenarios(vec![ + Scenario { + name: "normal run completion closes supervision", + events: vec![ + catalog(1, CatalogDisposition::Retained), + completed(RUN_1, RunCompletion::Completed), + ], + effects: vec![ + relaunch(RUN_1, 1, 7), + SupervisorEffect::Close(CloseReason::RunCompleted), + ], + phase: Phase::Closed, + }, + Scenario { + name: "failed run completion closes supervision", + events: vec![ + catalog(1, CatalogDisposition::Retained), + completed(RUN_1, RunCompletion::Failed), + ], + effects: vec![ + relaunch(RUN_1, 1, 7), + SupervisorEffect::Close(CloseReason::RunFailed), + ], + phase: Phase::Closed, + }, + Scenario { + name: "close settles a delayed supervisor", + events: vec![ + catalog(1, CatalogDisposition::Unavailable), + SupervisorEvent::Close, + catalog(2, CatalogDisposition::Retained), + ], + effects: vec![ + SupervisorEffect::Wait(WaitFor::Catalog), + SupervisorEffect::Close(CloseReason::Requested), + SupervisorEffect::Preserve(PreserveReason::Closed), + ], + phase: Phase::Closed, + }, + Scenario { + name: "stale run events and current gateway preserve ownership", + events: vec![ + catalog(1, CatalogDisposition::Retained), + SupervisorEvent::AcceptedInput(RunId(99)), + SupervisorEvent::TerminalSettlement(RunId(99)), + completed(RunId(99), RunCompletion::Interrupted), + SupervisorEvent::GatewayGeneration(7), + ], + effects: vec![ + relaunch(RUN_1, 1, 7), + SupervisorEffect::Preserve(PreserveReason::AlreadyHandled), + SupervisorEffect::Preserve(PreserveReason::AlreadyHandled), + SupervisorEffect::Preserve(PreserveReason::AlreadyHandled), + SupervisorEffect::Preserve(PreserveReason::CurrentRun), + ], + phase: Phase::Running, + }, + ]); +} + +#[test] +fn deferred_catalog_settlement_cancels_and_relaunches_exactly_once() { + let events = [ + catalog(1, CatalogDisposition::Retained), + SupervisorEvent::AcceptedInput(RUN_1), + catalog(2, CatalogDisposition::Replacement), + catalog(2, CatalogDisposition::Replacement), + SupervisorEvent::TerminalSettlement(RUN_1), + SupervisorEvent::TerminalSettlement(RUN_1), + completed(RUN_1, RunCompletion::Interrupted), + completed(RUN_1, RunCompletion::Interrupted), + ]; + let (state, effects) = apply(7, &events); + assert_eq!( + effects + .iter() + .filter(|effect| **effect == SupervisorEffect::Cancel(CancelOrigin::Catalog)) + .count(), + 1, + "duplicate generations and terminal events cannot cancel twice" + ); + assert_eq!( + effects + .iter() + .filter(|effect| matches!(effect, SupervisorEffect::Relaunch(_))) + .count(), + 2, + "one initial run and one replacement run launch" + ); + assert_eq!(state.active_run, Some(RUN_2)); + assert_eq!(state.catalog_generation, Some(2)); +} + +#[test] +fn overlapping_retirement_causes_cancel_only_the_owned_run() { + let events = [ + catalog(1, CatalogDisposition::Retained), + SupervisorEvent::GatewayGeneration(8), + SupervisorEvent::GatewayGeneration(9), + SupervisorEvent::OperatorCancellation, + catalog(2, CatalogDisposition::Replacement), + completed(RUN_1, RunCompletion::Interrupted), + ]; + let (state, effects) = apply(7, &events); + assert_eq!( + effects + .iter() + .filter(|effect| matches!(effect, SupervisorEffect::Cancel(_))) + .count(), + 1, + "the first retirement owns cancellation through run completion" + ); + assert_eq!( + effects.last(), + Some(&relaunch(RUN_2, 2, 9)), + "the one replacement consumes the latest catalog and Gateway generations" + ); + assert_eq!(state.active_run, Some(RUN_2)); +} + +#[test] +fn terminal_settlement_is_scoped_to_the_run_that_accepted_input() { + let events = [ + catalog(1, CatalogDisposition::Retained), + SupervisorEvent::OperatorCancellation, + completed(RUN_1, RunCompletion::Interrupted), + SupervisorEvent::AcceptedInput(RUN_2), + catalog(2, CatalogDisposition::Replacement), + SupervisorEvent::TerminalSettlement(RUN_1), + SupervisorEvent::TerminalSettlement(RUN_2), + SupervisorEvent::TerminalSettlement(RUN_2), + ]; + let (state, effects) = apply(7, &events); + + assert_eq!( + effects[5], + SupervisorEffect::Preserve(PreserveReason::AlreadyHandled), + "a stale terminal event cannot settle the current run" + ); + assert_eq!( + effects + .iter() + .filter(|effect| **effect == SupervisorEffect::Cancel(CancelOrigin::Catalog)) + .count(), + 1, + "the accepted run's terminal event retires it once" + ); + assert_eq!(state.phase, Phase::Cancelling); + assert_eq!(state.accepted_run, None); +} + +#[test] +fn close_effect_is_emitted_exactly_once() { + let events = [ + catalog(1, CatalogDisposition::Retained), + SupervisorEvent::Close, + SupervisorEvent::Close, + completed(RUN_1, RunCompletion::Interrupted), + completed(RUN_1, RunCompletion::Completed), + ]; + let (state, effects) = apply(7, &events); + + assert_eq!( + effects + .iter() + .filter(|effect| matches!(effect, SupervisorEffect::Close(_))) + .count(), + 1, + "close owns terminal settlement despite later run notifications" + ); + assert_eq!(state.phase, Phase::Closed); + assert_eq!(state.active_run, None); +} diff --git a/crates/workshop-server/tests/common/mod.rs b/crates/workshop-server/tests/common/mod.rs index 9cf86b9e..d847e26f 100644 --- a/crates/workshop-server/tests/common/mod.rs +++ b/crates/workshop-server/tests/common/mod.rs @@ -16,9 +16,7 @@ use futures_util::{SinkExt, StreamExt}; use tokio::net::TcpStream; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; -use workshop_server::{ - AgentsConfig, Config, GatewayConfig, ResolvedGateway, ServerConfig, ServerHandle, -}; +use workshop_server::{AgentsConfig, Config, GatewayConfig, ServerConfig, ServerHandle}; /// How long one frame read may take before the test fails: generous enough /// for a slow CI runner, far below any test's own deadline. @@ -52,9 +50,7 @@ impl TestServer { }, agents: AgentsConfig::default(), }; - let gateway = ResolvedGateway::from_config(&config.gateway); - let handle = workshop_server::spawn_with_routes(config, gateway, |_| axum::Router::new()) - .expect("the workshop server spawns"); + let handle = workshop_server::fixtures::spawn(config).expect("the workshop server spawns"); Self { handle: Some(handle), _state_dir: state_dir, @@ -62,7 +58,7 @@ impl TestServer { } /// The `ws://` URL of `path` on this server, for example `/ws` or - /// `/stt`. + /// `/v1/realtime`. pub(crate) fn ws_url(&self, path: &str) -> String { let url = self .handle @@ -74,6 +70,42 @@ impl TestServer { .expect("the server URL scheme is http"); format!("ws{rest}{path}") } + + /// The `http://` URL of `path` on this server. + pub(crate) fn http_url(&self, path: &str) -> String { + format!( + "{}{path}", + self.handle + .as_ref() + .expect("the handle is held until drop") + .url() + ) + } + + /// Atomically replaces the local sidecar endpoint and bearer used by + /// every gateway-dependent Workshop path. + pub(crate) fn replace_gateway(&self, gateway_base_url: &str, api_key: &str) { + let port = url::Url::parse(gateway_base_url) + .expect("the replacement gateway URL parses") + .port() + .expect("the replacement gateway URL carries a port"); + let file = shared_sidecar::ConnectionFile { + port, + api_key: api_key.to_owned(), + pid: std::process::id(), + epoch: 1_757_000_000, + version: "test".to_owned(), + started_at: "2026-09-07T14:14:31Z".to_owned(), + }; + let validated = shared_sidecar::ValidatedConnection::validate_for_test(file) + .expect("the replacement endpoint validates"); + self.handle + .as_ref() + .expect("the handle is held until drop") + .gateway_updater() + .replace_sidecar(&validated) + .expect("the replacement endpoint publishes"); + } } impl Drop for TestServer { diff --git a/crates/workshop-server/tests/it/agents.rs b/crates/workshop-server/tests/it/agents.rs index 410422a7..45f610d7 100644 --- a/crates/workshop-server/tests/it/agents.rs +++ b/crates/workshop-server/tests/it/agents.rs @@ -12,15 +12,18 @@ reason = "test helpers fail by panicking with the invariant named" )] +use std::sync::{Arc, Mutex}; use std::time::Duration; use axum::Router; +use axum::body::Body; use axum::http::header; use axum::response::{IntoResponse, Response}; use axum::routing::post; use serde_json::json; +use tokio::sync::Notify; -use workshop_server::fixtures::state_with_gateway; +use workshop_server::fixtures::{gateway_updater, state_with_gateway}; use workshop_server::{ AgentsConfig, AppState, Config, GatewayConfig, ResolvedGateway, ServerConfig, router, }; @@ -77,6 +80,54 @@ async fn echo_completions(body: String) -> Response { ([(header::CONTENT_TYPE, "text/event-stream")], sse).into_response() } +/// Accepts one completion and then leaves its SSE body open forever. +fn hanging_completions(started: &Notify) -> Response { + started.notify_one(); + let stream = futures_util::stream::pending::>(); + ( + [(header::CONTENT_TYPE, "text/event-stream")], + Body::from_stream(stream), + ) + .into_response() +} + +/// Records one completion body for endpoint and binding assertions. +fn record_request(requests: &Mutex>, body: &str) { + requests + .lock() + .expect("the request capture lock is healthy") + .push(serde_json::from_str(body).expect("the request is JSON")); +} + +/// Asserts one replacement request and its retained history boundary. +fn assert_replacement_request( + requests: &Mutex>, + model: &str, + retained_input: &str, + current_input: &str, +) { + let requests = requests + .lock() + .expect("the request capture lock is healthy"); + assert_eq!(requests.len(), 1, "one replacement run dispatches"); + assert_eq!( + requests[0]["model"], model, + "the replacement request uses the selected catalog" + ); + assert_eq!( + requests[0]["messages"][0]["content"], retained_input, + "the replacement run receives the accepted input from retained history" + ); + assert_eq!( + requests[0]["messages"] + .as_array() + .and_then(|messages| messages.last()) + .and_then(|message| message["content"].as_str()), + Some(current_input), + "the new turn follows the retained accepted input" + ); +} + /// Binds the workshop router against an echoing SSE mock gateway, with /// one discovered agent (`echo`) and the retained catalog already /// holding `test-model`. Returns the server's base `ws://` URL, the @@ -84,6 +135,11 @@ async fn echo_completions(body: String) -> Response { async fn spawn_agent_server() -> (String, tempfile::TempDir, AppState) { let base_url = spawn_gateway(Router::new().route("/v1/chat/completions", post(echo_completions))).await; + spawn_agent_server_for_gateway(base_url).await +} + +/// Binds the workshop router to an injected Gateway endpoint. +async fn spawn_agent_server_for_gateway(base_url: String) -> (String, tempfile::TempDir, AppState) { let dir = tempfile::TempDir::new().expect("tempdir"); let agents_dir = dir.path().join("agents"); std::fs::create_dir(&agents_dir).expect("the agents directory creates"); @@ -120,6 +176,27 @@ async fn spawn_agent_server() -> (String, tempfile::TempDir, AppState) { (format!("ws://{addr}"), dir, state) } +/// Publishes `base_url` as the next complete Gateway generation. +fn replace_gateway(state: &AppState, base_url: &str, epoch: u64) { + let port = url::Url::parse(base_url) + .expect("the replacement URL parses") + .port() + .expect("the replacement URL carries a port"); + let validated = + shared_sidecar::ValidatedConnection::validate_for_test(shared_sidecar::ConnectionFile { + port, + api_key: "replacement-key".to_owned(), + pid: std::process::id(), + epoch, + version: "test".to_owned(), + started_at: "2026-09-07T14:14:31Z".to_owned(), + }) + .expect("the replacement Gateway validates"); + gateway_updater(state) + .replace_sidecar(&validated) + .expect("the replacement Gateway publishes"); +} + /// Connects to `/agents/ws` and consumes the connect-time agent list. async fn connect(base: &str) -> JsonSocket { let mut socket = JsonSocket::connect(&format!("{base}/agents/ws")).await; @@ -134,12 +211,17 @@ async fn connect(base: &str) -> JsonSocket { /// Launches the echo agent on `socket` and returns the session id from /// the acknowledgment frame. async fn launch_echo(socket: &mut JsonSocket) -> String { + launch_agent(socket, "echo").await +} + +/// Launches `agent` and returns its acknowledged session id. +async fn launch_agent(socket: &mut JsonSocket, agent: &str) -> String { socket - .send_json(&json!({ "type": "launch", "agent": "echo" })) + .send_json(&json!({ "type": "launch", "agent": agent })) .await; let frame = socket.recv_json().await; assert_eq!(frame["type"], "agent_session"); - assert_eq!(frame["agent"], "echo"); + assert_eq!(frame["agent"], agent); frame["session"] .as_str() .expect("the acknowledgment carries the session id") @@ -415,6 +497,292 @@ async fn turn_cancel_returns_to_waiting_with_input_cancelled_and_no_error_frame( socket.close().await; } +#[tokio::test] +async fn gateway_replacement_interrupts_a_catalog_wait_on_accepted_input() { + let started = Arc::new(Notify::new()); + let request_started = Arc::clone(&started); + let original_requests = Arc::new(Mutex::new(Vec::new())); + let captured_original = Arc::clone(&original_requests); + let original = spawn_gateway(Router::new().route( + "/v1/chat/completions", + post(move |body: String| { + let request_started = Arc::clone(&request_started); + let captured_original = Arc::clone(&captured_original); + async move { + record_request(&captured_original, &body); + hanging_completions(&request_started) + } + }), + )) + .await; + let (base, _dir, state) = spawn_agent_server_for_gateway(original).await; + state + .catalog() + .publish(vec![json!({ "id": "model-a", "object": "model" })]); + state.menu().reconcile_catalog_for_test(); + state + .menu() + .set_selected("model-a") + .expect("the original model becomes selected"); + let mut socket = connect(&base).await; + let session = launch_agent(&mut socket, "chat").await; + let token = next_wait_token(&mut socket).await; + + let catalog_state = state.clone(); + state + .agents() + .deliver_input_after_acceptance_for_test( + &session, + workshop_server::InputResponse { + token, + text: "accepted across replacements".to_owned(), + }, + move || { + catalog_state.catalog().publish(vec![json!({ + "id": "model-b", + "object": "model", + })]); + }, + ) + .expect("the session remains registered") + .expect("the accepted input resumes its run"); + tokio::time::timeout(Duration::from_secs(10), started.notified()) + .await + .expect("the accepted turn reaches the hanging Gateway"); + assert_eq!( + original_requests + .lock() + .expect("the request capture lock is healthy")[0]["model"], + "model-a", + "the accepted run keeps its frozen catalog while retirement is deferred" + ); + + state.menu().reconcile_catalog_for_test(); + state + .menu() + .set_selected("model-b") + .expect("the replacement model becomes selected"); + let replacement_requests = Arc::new(Mutex::new(Vec::new())); + let captured_replacement = Arc::clone(&replacement_requests); + let replacement = spawn_gateway(Router::new().route( + "/v1/chat/completions", + post(move |body: String| { + let captured_replacement = Arc::clone(&captured_replacement); + async move { + record_request(&captured_replacement, &body); + echo_completions(body).await + } + }), + )) + .await; + replace_gateway(&state, &replacement, 1_757_000_000); + + let fresh = tokio::time::timeout(Duration::from_secs(10), next_wait_token(&mut socket)) + .await + .expect("Gateway replacement overrides the catalog settlement wait"); + answer(&mut socket, &fresh, "after replacement").await; + let turn = collect_turn(&mut socket).await; + assert_replacement_request( + &replacement_requests, + "model-b", + "accepted across replacements", + "after replacement", + ); + assert_eq!( + delta_text(&turn), + "echo:after replacement", + "the relaunched run uses the replacement Gateway" + ); + socket.close().await; +} + +#[tokio::test] +async fn retained_catalog_generation_replays_on_the_replacement_gateway() { + let started = Arc::new(Notify::new()); + let request_started = Arc::clone(&started); + let original = spawn_gateway(Router::new().route( + "/v1/chat/completions", + post(move |body: String| { + let request_started = Arc::clone(&request_started); + async move { + assert_eq!( + serde_json::from_str::(&body).expect("the request is JSON") + ["model"], + "model-a" + ); + hanging_completions(&request_started) + } + }), + )) + .await; + let (base, _dir, state) = spawn_agent_server_for_gateway(original).await; + state + .catalog() + .publish(vec![json!({ "id": "model-a", "object": "model" })]); + state.menu().reconcile_catalog_for_test(); + state + .menu() + .set_selected("model-a") + .expect("the original model becomes selected"); + let mut socket = connect(&base).await; + let session = launch_agent(&mut socket, "chat").await; + let token = next_wait_token(&mut socket).await; + + let catalog_state = state.clone(); + state + .agents() + .deliver_input_after_acceptance_for_test( + &session, + workshop_server::InputResponse { + token, + text: "retained before replay".to_owned(), + }, + move || { + catalog_state + .catalog() + .publish(vec![json!({ "id": "model-b", "object": "model" })]); + }, + ) + .expect("the session remains registered") + .expect("the accepted input resumes its run"); + tokio::time::timeout(Duration::from_secs(10), started.notified()) + .await + .expect("the replacement generation is observed before the old request starts"); + + state + .catalog() + .publish(vec![json!({ "id": "model-a", "object": "model" })]); + let replacement_requests = Arc::new(Mutex::new(Vec::new())); + let captured_replacement = Arc::clone(&replacement_requests); + let replacement = spawn_gateway(Router::new().route( + "/v1/chat/completions", + post(move |body: String| { + let captured_replacement = Arc::clone(&captured_replacement); + async move { + record_request(&captured_replacement, &body); + echo_completions(body).await + } + }), + )) + .await; + replace_gateway(&state, &replacement, 1_757_000_001); + + let fresh = tokio::time::timeout(Duration::from_secs(10), next_wait_token(&mut socket)) + .await + .expect("the retained generation relaunches instead of resolving stale model-b"); + answer(&mut socket, &fresh, "after retained replay").await; + let turn = collect_turn(&mut socket).await; + assert_eq!(delta_text(&turn), "echo:after retained replay"); + assert_replacement_request( + &replacement_requests, + "model-a", + "retained before replay", + "after retained replay", + ); + socket.close().await; +} + +#[tokio::test] +async fn unavailable_catalog_waits_without_relaunching_stale_bindings() { + let started = Arc::new(Notify::new()); + let request_started = Arc::clone(&started); + let original = spawn_gateway(Router::new().route( + "/v1/chat/completions", + post(move || { + let request_started = Arc::clone(&request_started); + async move { hanging_completions(&request_started) } + }), + )) + .await; + let (base, _dir, state) = spawn_agent_server_for_gateway(original).await; + state + .catalog() + .publish(vec![json!({ "id": "model-a", "object": "model" })]); + state.menu().reconcile_catalog_for_test(); + state + .menu() + .set_selected("model-a") + .expect("the original model becomes selected"); + let mut socket = connect(&base).await; + let session = launch_agent(&mut socket, "chat").await; + let token = next_wait_token(&mut socket).await; + + let catalog_state = state.clone(); + state + .agents() + .deliver_input_after_acceptance_for_test( + &session, + workshop_server::InputResponse { + token, + text: "retained while unavailable".to_owned(), + }, + move || { + catalog_state + .catalog() + .publish(vec![json!({ "id": "model-b", "object": "model" })]); + }, + ) + .expect("the session remains registered") + .expect("the accepted input resumes its run"); + tokio::time::timeout(Duration::from_secs(10), started.notified()) + .await + .expect("the replacement generation is observed before the old request starts"); + + state.menu().reconcile_catalog_for_test(); + state + .menu() + .set_selected("model-b") + .expect("the pending replacement model becomes selected"); + state.catalog().publish(Vec::new()); + let replacement_started = Arc::new(Notify::new()); + let replacement_request_started = Arc::clone(&replacement_started); + let replacement_requests = Arc::new(Mutex::new(Vec::new())); + let captured_replacement = Arc::clone(&replacement_requests); + let replacement = spawn_gateway(Router::new().route( + "/v1/chat/completions", + post(move |body: String| { + let replacement_request_started = Arc::clone(&replacement_request_started); + let captured_replacement = Arc::clone(&captured_replacement); + async move { + record_request(&captured_replacement, &body); + replacement_request_started.notify_one(); + echo_completions(body).await + } + }), + )) + .await; + replace_gateway(&state, &replacement, 1_757_000_002); + + assert!( + tokio::time::timeout(Duration::from_millis(250), replacement_started.notified()) + .await + .is_err(), + "an unavailable catalog cannot relaunch model-b on the replacement Gateway" + ); + + state + .catalog() + .publish(vec![json!({ "id": "model-c", "object": "model" })]); + state.menu().reconcile_catalog_for_test(); + state + .menu() + .set_selected("model-c") + .expect("the newly available model becomes selected"); + let fresh = tokio::time::timeout(Duration::from_secs(10), next_wait_token(&mut socket)) + .await + .expect("a later usable catalog relaunches the waiting session"); + answer(&mut socket, &fresh, "after unavailable").await; + let turn = collect_turn(&mut socket).await; + assert_eq!(delta_text(&turn), "echo:after unavailable"); + assert_replacement_request( + &replacement_requests, + "model-c", + "retained while unavailable", + "after unavailable", + ); + socket.close().await; +} + #[tokio::test] async fn two_sessions_do_not_cross_talk() { let (base, _dir, _state) = spawn_agent_server().await; diff --git a/crates/workshop-server/tests/it/chat_gate.rs b/crates/workshop-server/tests/it/chat_gate.rs index 87217ece..51852d3d 100644 --- a/crates/workshop-server/tests/it/chat_gate.rs +++ b/crates/workshop-server/tests/it/chat_gate.rs @@ -1,4 +1,4 @@ -//! THE PARITY GATE: six in-process tests over the SSE mock gateway, each +//! THE PARITY GATE: seven in-process tests over the SSE mock gateway, each //! pinned to a behavior the built-in `chat` agent must keep. The agent //! replaced the direct-to-gateway chat relay; these tests hold the parity //! the relay established. @@ -22,7 +22,7 @@ use axum::Router; use axum::body::Body; use axum::http::{StatusCode, header}; use axum::response::{IntoResponse, Response}; -use axum::routing::post; +use axum::routing::{get, post}; use futures_util::StreamExt as _; use serde_json::json; use tokio::sync::broadcast; @@ -37,7 +37,7 @@ use promptforge_model_client::client::{ use promptforge_model_client::model::{ModelCatalog, ModelDescriptor, ModelId, ThinkingMode}; use promptforge_store::StoreRef; use promptforge_tools::{Tool, ToolCatalog}; -use workshop_server::fixtures::state_with_gateway; +use workshop_server::fixtures::{gateway_updater, state_with_gateway}; use workshop_server::{ AgentsConfig, AppState, Config, GatewayConfig, InputFrame, InputResponse, ResolvedGateway, ServerConfig, UserInputTool, WaitRegistry, WorkshopObserver, deliver_input_response, router, @@ -116,6 +116,21 @@ fn gate_completions(captured: &CapturedRequests, body: &str) -> Response { ([(header::CONTENT_TYPE, "text/event-stream")], sse).into_response() } +/// A successful profile switch whose refreshed catalog replaces the +/// launch-time model with `model-b`. +async fn switch_to_model_b() -> Response { + ( + [(header::CONTENT_TYPE, "text/event-stream")], + concat!( + "data: {\"stage\":\"loading-profile\"}\n\n", + "data: {\"stage\":\"stopping-models\"}\n\n", + "data: {\"stage\":\"starting-models\"}\n\n", + "data: {\"status\":\"ready\",\"profile\":\"beta\"}\n\n", + ), + ) + .into_response() +} + /// One workshop server over the gate mock. The agents directory is /// missing on purpose: every `chat` launch runs the embedded built-in. struct GateServer { @@ -134,15 +149,43 @@ struct GateServer { /// Spawns the gate server with `models` in the retained catalog and the /// first of them selected in the menu. async fn spawn_chat_server(models: &[&str]) -> GateServer { + spawn_chat_server_with_selection(models, models.first().copied()).await +} + +/// Spawns the gate server with an explicit menu selection. `None` keeps +/// the catalog available to the launched agent while its live `ui()` +/// snapshot has no selected binding. +async fn spawn_chat_server_with_selection(models: &[&str], selected: Option<&str>) -> GateServer { let captured = CapturedRequests::default(); let mock = Arc::clone(&captured); - let gateway_url = spawn_gateway(Router::new().route( - "/v1/chat/completions", - post(move |body: String| { - let captured = Arc::clone(&mock); - async move { gate_completions(&captured, &body) } - }), - )) + let gateway_url = spawn_gateway( + Router::new() + .route( + "/v1/chat/completions", + post(move |body: String| { + let captured = Arc::clone(&mock); + async move { gate_completions(&captured, &body) } + }), + ) + .route("/admin/switch-profile", post(switch_to_model_b)) + .route( + "/admin/profiles", + get(|| async { axum::Json(json!({"profiles": ["main", "beta"]})) }), + ) + .route( + "/admin/status", + get(|| async { axum::Json(json!({"profile": "beta"})) }), + ) + .route( + "/v1/models", + get(|| async { + axum::Json(json!({ + "object": "list", + "data": [{"id": "model-b", "object": "model"}], + })) + }), + ), + ) .await; let dir = tempfile::TempDir::new().expect("tempdir"); let config = Config { @@ -167,10 +210,12 @@ async fn spawn_chat_server(models: &[&str]) -> GateServer { .map(|id| json!({ "id": id, "object": "model" })) .collect(), ); - state - .menu() - .set_selected(models[0]) - .expect("the first model is in the retained catalog"); + if let Some(selected) = selected { + state + .menu() + .set_selected(selected) + .expect("the selected model is in the retained catalog"); + } let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind the gate test server"); @@ -220,6 +265,15 @@ async fn launch_chat(socket: &mut JsonSocket) -> String { .to_owned() } +/// Asserts that no input wait or error arrives during `duration`. +async fn assert_chat_quiet(socket: &mut JsonSocket, duration: Duration) { + let frame = tokio::time::timeout(duration, socket.recv_json()).await; + assert!( + frame.is_err(), + "chat must stay dormant until a chat-capable catalog exists, got {frame:?}" + ); +} + /// The `(role, content)` pairs of one captured request's message list. fn role_content_pairs(request: &serde_json::Value) -> Vec<(String, String)> { request["messages"] @@ -246,156 +300,6 @@ fn pair(role: &str, content: &str) -> (String, String) { (role.to_owned(), content.to_owned()) } -/// GATE 1 - multi-turn history. Current-chat behavior: the conversation -/// accumulates turn over turn, and what the user typed reaches the model -/// byte-exact with no untrusted envelope around it. -#[tokio::test] -async fn gate_history_accumulates_across_three_turns_byte_exact() { - let server = spawn_chat_server(&["test-model"]).await; - let mut socket = connect_chat(&server.ws_base).await; - let _session = launch_chat(&mut socket).await; - - let gnarly = "line1\r\nline2 \"quoted\" {\"text\":\"decoy\"} \\slash 🦀"; - let inputs = ["first ping", gnarly, "third"]; - let mut token = next_wait_token(&mut socket).await; - for input in inputs { - answer(&mut socket, &token, input).await; - let turn = collect_turn(&mut socket).await; - assert_eq!(delta_text(&turn), format!("echo:{input}")); - token = wait_after(&mut socket, &turn).await; - } - - { - let requests = server.captured.lock().expect("the capture lock is healthy"); - assert_eq!(requests.len(), 3, "three turns are three model rounds"); - assert_eq!( - role_content_pairs(&requests[0]), - vec![pair("user", "first ping")], - "the first round carries exactly the first input" - ); - assert_eq!( - role_content_pairs(&requests[1]), - vec![ - pair("user", "first ping"), - pair("assistant", "echo:first ping"), - pair("user", gnarly), - ], - "the second round carries the first exchange plus the new input, \ - the gnarly user text byte-exact and envelope-free" - ); - assert_eq!( - role_content_pairs(&requests[2]), - vec![ - pair("user", "first ping"), - pair("assistant", "echo:first ping"), - pair("user", gnarly), - pair("assistant", &format!("echo:{gnarly}")), - pair("user", "third"), - ], - "the third round carries the whole accumulated conversation" - ); - } - socket.close().await; -} - -/// GATE 2 - live streaming. Current-chat behavior: while the model -/// generates, the client sees answer text and reasoning arrive as live -/// chunks, and the completed reply supersedes them under the same id. -#[tokio::test] -async fn gate_streaming_delivers_text_and_reasoning_deltas_then_the_reply() { - let server = spawn_chat_server(&["test-model"]).await; - let mut socket = connect_chat(&server.ws_base).await; - let _session = launch_chat(&mut socket).await; - - let token = next_wait_token(&mut socket).await; - answer(&mut socket, &token, "ping").await; - let turn = collect_turn(&mut socket).await; - - let reasoning: String = turn - .deltas - .iter() - .filter(|delta| delta["kind"] == "reasoning") - .filter_map(|delta| delta["content"].as_str()) - .collect(); - assert_eq!( - reasoning, "mm", - "reasoning streams live on its own side channel during generation" - ); - assert!( - turn.deltas - .iter() - .filter(|delta| delta["kind"] == "text") - .count() - >= 2, - "the mock splits content, so generation provably streams in chunks" - ); - assert_eq!( - delta_text(&turn), - "echo:ping", - "the live text chunks assemble the reply" - ); - - let reply = turn - .events - .last() - .expect("the turn ends with its reply event"); - assert_eq!(reply["event"]["kind"], "agent_message"); - assert_eq!( - reply["event"]["content"], "echo:ping", - "the completed reply arrives after the deltas it supersedes" - ); - assert!( - turn.deltas - .iter() - .all(|delta| delta["reply"] == reply["reply"]), - "deltas and the completed reply share the superseding id" - ); - socket.close().await; -} - -/// GATE 3 - model switch. Current-chat behavior: selecting another model -/// takes effect on the next turn, and the reply is attributed to the -/// model that produced it. -#[tokio::test] -async fn gate_model_switch_takes_effect_next_turn_with_attribution() { - let server = spawn_chat_server(&["model-a", "model-b"]).await; - let mut socket = connect_chat(&server.ws_base).await; - let _session = launch_chat(&mut socket).await; - - let token = next_wait_token(&mut socket).await; - answer(&mut socket, &token, "one").await; - let turn = collect_turn(&mut socket).await; - let reply = turn.events.last().expect("the first turn completes"); - assert_eq!( - reply["event"]["model"], "model-a", - "the first turn runs on the selected model" - ); - - server - .state - .menu() - .set_selected("model-b") - .expect("model-b is in the retained catalog"); - - let token = wait_after(&mut socket, &turn).await; - answer(&mut socket, &token, "two").await; - let turn = collect_turn(&mut socket).await; - let reply = turn.events.last().expect("the second turn completes"); - assert_eq!( - reply["event"]["model"], "model-b", - "the switch takes effect next turn; the reply event carries the new model id" - ); - { - let requests = server.captured.lock().expect("the capture lock is healthy"); - assert_eq!(requests[0]["model"], "model-a"); - assert_eq!( - requests[1]["model"], "model-b", - "the request itself names the newly selected model" - ); - } - socket.close().await; -} - /// The running relaunch of the restart gate: everything the test drives /// and tears down. struct RestoredChat { @@ -460,185 +364,8 @@ fn spawn_restored_chat( } } -/// GATE 4 - restart. Current-chat behavior it replaces: a conversation -/// does not die with its process. The persisted JSONL alone restores it, -/// and the relaunched agent resumes waiting for input - the supervisor's -/// own relaunch shape driven with the log reloaded from disk. -#[tokio::test] -async fn gate_restart_reloads_the_jsonl_and_resumes_waiting_for_input() { - let server = spawn_chat_server(&["test-model"]).await; - let mut socket = connect_chat(&server.ws_base).await; - let session = launch_chat(&mut socket).await; - - let token = next_wait_token(&mut socket).await; - answer(&mut socket, &token, "ping").await; - let live = collect_turn(&mut socket).await; - assert_eq!(delta_text(&live), "echo:ping"); - socket.close().await; - assert!( - server.state.agents().close(&session), - "the session ends; only the JSONL survives" - ); - - let log_path = server - .dir - .path() - .join("sessions") - .join(format!("{session}.jsonl")); - let restored = - Arc::new(WorkshopObserver::load_from(&log_path).expect("the persisted JSONL reloads")); - assert_eq!( - restored.len(), - 4, - "the whole conversation restores: input, tool result, thinking, reply" - ); - assert_eq!( - restored.get(0).map(|event| event.content), - Some("ping".to_owned()) - ); - assert_eq!( - restored.get(3).map(|event| event.content), - Some("echo:ping".to_owned()) - ); - - let mut relaunch = spawn_restored_chat(&restored, &session, &server.gateway_url); - - // The relaunched agent resumes waiting: its first act is user_input. - let frame = tokio::time::timeout(Duration::from_secs(10), relaunch.frames.recv()) - .await - .expect("the relaunched agent asks for input") - .expect("the frames channel is open"); - let InputFrame::Required { token } = frame else { - panic!("the relaunched agent must open a wait, got {frame:?}"); - }; - - // Answering proves the conversation itself was restored: the next - // round shows the model the old exchange plus the new input. - let mut entries = restored.subscribe(); - deliver_input_response( - restored.as_ref(), - &relaunch.waits, - &session, - "chat", - InputResponse { - token, - text: "and back".to_owned(), - }, - ) - .expect("the wait completes"); - let reply = tokio::time::timeout(Duration::from_secs(10), async { - loop { - let event = entries.recv().await.expect("the log broadcast stays open"); - if event.kind == RuntimeEventKind::AssistantReply { - break event; - } - } - }) - .await - .expect("the restarted agent completes a round"); - assert_eq!(reply.content, "echo:and back"); - { - let requests = server.captured.lock().expect("the capture lock is healthy"); - assert_eq!(requests.len(), 2); - assert_eq!( - role_content_pairs(&requests[1]), - vec![ - pair("user", "ping"), - pair("assistant", "echo:ping"), - pair("user", "and back"), - ], - "the reloaded JSONL alone rebuilt the conversation the model sees" - ); - } - - // Teardown: the loop is back on user_input; cancellation ends it. - relaunch.cancel.cancel(); - let result = relaunch.run.await.expect("the relaunched run joins"); - assert!( - matches!(result, Err(AgentError::Interrupted)), - "cancellation ends the relaunched run cleanly, got {result:?}" - ); -} - -/// GATE 5 - turn-cancel. Current-chat behavior: the stop button kills -/// generation mid-stream without an error, and the chat is immediately -/// usable again. -#[tokio::test] -async fn gate_cancel_mid_generation_returns_to_waiting_and_next_input_works() { - let server = spawn_chat_server(&["test-model"]).await; - let mut socket = connect_chat(&server.ws_base).await; - let _session = launch_chat(&mut socket).await; - - let token = next_wait_token(&mut socket).await; - answer(&mut socket, &token, "hang").await; - // Generation is provably live: a text chunk of the never-finishing - // stream has reached the wire. - let delta = socket - .recv_until(Duration::from_secs(10), |frame| { - assert_ne!( - frame["type"], "error", - "the hanging turn is not an error: {frame}" - ); - frame["type"] == "agent_delta" && frame["kind"] == "text" - }) - .await; - assert_eq!(delta["content"], "nev"); - - socket.send_json(&json!({ "type": "cancel" })).await; - - // Cancellation is a stop reason: the relaunched run returns to - // waiting, and next_wait_token refuses error frames on the way - - // which asserts exactly the no-error contract. - let fresh = next_wait_token(&mut socket).await; - assert_ne!(fresh, token, "the relaunched run opens a fresh wait"); - answer(&mut socket, &fresh, "after cancel").await; - let turn = collect_turn(&mut socket).await; - assert_eq!( - delta_text(&turn), - "echo:after cancel", - "the next input after a mid-generation cancel runs a full turn" - ); - assert!( - turn.events - .iter() - .all(|event| event["event"]["content"] != "echo:hang"), - "the cancelled generation never completes into a reply" - ); - socket.close().await; -} - -/// GATE 6 - error survival. Current-chat behavior: a failed completion -/// surfaces an error to the operator and the chat keeps working - the -/// behavior that replaces the relay's gateway-health short-circuit. -#[tokio::test] -async fn gate_model_failure_surfaces_an_error_and_the_next_input_works() { - let server = spawn_chat_server(&["test-model"]).await; - let mut socket = connect_chat(&server.ws_base).await; - let _session = launch_chat(&mut socket).await; - - let token = next_wait_token(&mut socket).await; - answer(&mut socket, &token, "fail").await; - let error = socket - .recv_until(Duration::from_secs(10), |frame| frame["type"] == "error") - .await; - assert!( - error["message"] - .as_str() - .is_some_and(|message| message.contains("Model turn failed")), - "the failed model call surfaces as an error frame naming the boundary: {error}" - ); - - // The pcall'd failure never kills the program: the loop returns to - // user_input and the next turn is a normal one. - let fresh = next_wait_token(&mut socket).await; - answer(&mut socket, &fresh, "recovered").await; - let turn = collect_turn(&mut socket).await; - assert_eq!( - delta_text(&turn), - "echo:recovered", - "the next input still works after the failure" - ); - let reply = turn.events.last().expect("the recovery turn completes"); - assert_eq!(reply["event"]["content"], "echo:recovered"); - socket.close().await; -} +include!("chat_gate/protocol.rs"); +include!("chat_gate/lifecycle.rs"); +include!("chat_gate/recovery.rs"); +include!("chat_gate/overload.rs"); +include!("chat_gate/canonical_sequence.rs"); diff --git a/crates/workshop-server/tests/it/chat_gate/canonical_sequence.rs b/crates/workshop-server/tests/it/chat_gate/canonical_sequence.rs new file mode 100644 index 00000000..2d42be10 --- /dev/null +++ b/crates/workshop-server/tests/it/chat_gate/canonical_sequence.rs @@ -0,0 +1,51 @@ +/// GATE 1 - multi-turn history. Current-chat behavior: the conversation +/// accumulates turn over turn, and what the user typed reaches the model +/// byte-exact with no untrusted envelope around it. +#[tokio::test] +async fn gate_history_accumulates_across_three_turns_byte_exact() { + let server = spawn_chat_server(&["test-model"]).await; + let mut socket = connect_chat(&server.ws_base).await; + let _session = launch_chat(&mut socket).await; + + let gnarly = "line1\r\nline2 \"quoted\" {\"text\":\"decoy\"} \\slash 🦀"; + let inputs = ["first ping", gnarly, "third"]; + let mut token = next_wait_token(&mut socket).await; + for input in inputs { + answer(&mut socket, &token, input).await; + let turn = collect_turn(&mut socket).await; + assert_eq!(delta_text(&turn), format!("echo:{input}")); + token = wait_after(&mut socket, &turn).await; + } + + { + let requests = server.captured.lock().expect("the capture lock is healthy"); + assert_eq!(requests.len(), 3, "three turns are three model rounds"); + assert_eq!( + role_content_pairs(&requests[0]), + vec![pair("user", "first ping")], + "the first round carries exactly the first input" + ); + assert_eq!( + role_content_pairs(&requests[1]), + vec![ + pair("user", "first ping"), + pair("assistant", "echo:first ping"), + pair("user", gnarly), + ], + "the second round carries the first exchange plus the new input, \ + the gnarly user text byte-exact and envelope-free" + ); + assert_eq!( + role_content_pairs(&requests[2]), + vec![ + pair("user", "first ping"), + pair("assistant", "echo:first ping"), + pair("user", gnarly), + pair("assistant", &format!("echo:{gnarly}")), + pair("user", "third"), + ], + "the third round carries the whole accumulated conversation" + ); + } + socket.close().await; +} diff --git a/crates/workshop-server/tests/it/chat_gate/lifecycle.rs b/crates/workshop-server/tests/it/chat_gate/lifecycle.rs new file mode 100644 index 00000000..6f782cae --- /dev/null +++ b/crates/workshop-server/tests/it/chat_gate/lifecycle.rs @@ -0,0 +1,276 @@ +/// GATE 3 - model switch. Current-chat behavior: selecting another model +/// takes effect on the next turn, and the reply is attributed to the +/// model that produced it. +#[tokio::test] +async fn gate_model_switch_takes_effect_next_turn_with_attribution() { + let server = spawn_chat_server(&["model-a", "model-b"]).await; + let mut socket = connect_chat(&server.ws_base).await; + let _session = launch_chat(&mut socket).await; + + let token = next_wait_token(&mut socket).await; + answer(&mut socket, &token, "one").await; + let turn = collect_turn(&mut socket).await; + let reply = turn.events.last().expect("the first turn completes"); + assert_eq!( + reply["event"]["model"], "model-a", + "the first turn runs on the selected model" + ); + + server + .state + .menu() + .set_selected("model-b") + .expect("model-b is in the retained catalog"); + + let token = wait_after(&mut socket, &turn).await; + answer(&mut socket, &token, "two").await; + let turn = collect_turn(&mut socket).await; + let reply = turn.events.last().expect("the second turn completes"); + assert_eq!( + reply["event"]["model"], "model-b", + "the switch takes effect next turn; the reply event carries the new model id" + ); + { + let requests = server.captured.lock().expect("the capture lock is healthy"); + assert_eq!(requests[0]["model"], "model-a"); + assert_eq!( + requests[1]["model"], "model-b", + "the request itself names the newly selected model" + ); + } + socket.close().await; +} + +/// GATE 8 - delayed startup convergence. Launch acknowledgment may precede +/// the Gateway catalog, but the run itself waits for a chat-capable model. +/// Transcription-only publication neither readies nor starts chat. +#[tokio::test] +async fn gate_delayed_catalog_starts_chat_only_after_a_chat_model_arrives() { + let server = spawn_chat_server(&[]).await; + server.state.menu().set_gateway_reachable(true); + let mut socket = connect_chat(&server.ws_base).await; + let _session = launch_chat(&mut socket).await; + let mut workbench = JsonSocket::connect(&format!("{}/ws", server.ws_base)).await; + let initial = workbench + .recv_until(Duration::from_secs(10), |frame| frame["type"] == "models") + .await; + assert_eq!(initial["models"], json!([])); + + assert_chat_quiet(&mut socket, Duration::from_millis(150)).await; + server.state.catalog().publish(vec![ + json!({"id": "whisper-base-en", "kind": "transcription", "object": "model"}), + json!({"id": "whisper-small-en", "kind": "transcription", "object": "model"}), + json!({"id": "realtime-transcribe", "kind": "transcription", "object": "model"}), + ]); + server.state.menu().reconcile_catalog_for_test(); + assert!( + server.state.menu().set_selected("whisper-base-en").is_err(), + "a transcription-only entry cannot become the selected chat binding" + ); + let speech_only = workbench + .recv_until(Duration::from_secs(10), |frame| frame["type"] == "models") + .await; + assert_eq!( + speech_only["models"], + json!([]), + "the shared catalog feeding both model menus publishes no speech-only choices" + ); + assert_chat_quiet(&mut socket, Duration::from_millis(150)).await; + + server.state.catalog().publish(vec![ + json!({"id": "whisper-base-en", "kind": "transcription", "object": "model"}), + json!({"id": "claude-opus-4-6", "kind": "chat", "object": "model"}), + json!({"id": "whisper-small-en", "kind": "transcription", "object": "model"}), + json!({"id": "realtime-transcribe", "kind": "transcription", "object": "model"}), + ]); + server.state.menu().reconcile_catalog_for_test(); + server + .state + .menu() + .set_selected("claude-opus-4-6") + .expect("the chat model is selectable"); + let chat_only = workbench + .recv_until(Duration::from_secs(10), |frame| frame["type"] == "models") + .await; + assert_eq!( + chat_only["models"], + json!([{"id": "claude-opus-4-6", "kind": "chat", "object": "model"}]), + "both chat-facing choosers receive only the chat-capable model" + ); + let token = next_wait_token(&mut socket).await; + answer(&mut socket, &token, "after startup").await; + let turn = collect_turn(&mut socket).await; + assert_eq!(delta_text(&turn), "echo:after startup"); + { + let requests = server.captured.lock().expect("the capture lock is healthy"); + assert_eq!(requests.len(), 1, "exactly one completion was dispatched"); + assert_eq!(requests[0]["model"], "claude-opus-4-6"); + } + workbench.close().await; + socket.close().await; +} + +/// GATE 9 - catalog replacement during a profile switch. The supervisor +/// relaunches over retained history, while each individual run keeps its +/// own immutable model bindings. +#[tokio::test] +async fn gate_profile_switch_relaunches_chat_with_history_and_the_new_catalog() { + let server = spawn_chat_server(&["model-a"]).await; + server.state.menu().set_gateway_reachable(true); + server.state.menu().set_profiles( + vec!["main".to_owned(), "beta".to_owned()], + Some("main".to_owned()), + ); + let mut socket = connect_chat(&server.ws_base).await; + let _session = launch_chat(&mut socket).await; + + let token = next_wait_token(&mut socket).await; + answer(&mut socket, &token, "before switch").await; + let first = collect_turn(&mut socket).await; + assert_eq!(delta_text(&first), "echo:before switch"); + let _pending_wait = wait_after(&mut socket, &first).await; + + let mut workbench = JsonSocket::connect(&format!("{}/ws", server.ws_base)).await; + workbench + .send_json(&json!({"type": "switch_profile", "name": "beta"})) + .await; + workbench + .recv_until(Duration::from_secs(10), |frame| { + frame["type"] == "workbench" + && frame["active"] == "beta" + && frame["selected"] == "model-b" + && frame["chat_ready"] == true + }) + .await; + + let fresh = next_wait_token(&mut socket).await; + answer(&mut socket, &fresh, "after switch").await; + let second = collect_turn(&mut socket).await; + assert_eq!(delta_text(&second), "echo:after switch"); + { + let requests = server.captured.lock().expect("the capture lock is healthy"); + assert_eq!(requests.len(), 2, "one completion runs on each catalog"); + assert_eq!(requests[0]["model"], "model-a"); + assert_eq!(requests[1]["model"], "model-b"); + assert_eq!( + role_content_pairs(&requests[1]), + vec![ + pair("user", "before switch"), + pair("assistant", "echo:before switch"), + pair("user", "after switch"), + ], + "the catalog relaunch preserves the settled event history" + ); + } + workbench.close().await; + socket.close().await; +} + +/// GATE 10 - accepted-input replacement race. Catalog retirement waits +/// until the frozen run surfaces its lost binding, then relaunches on the +/// new generation without replaying or dropping the accepted input. +#[tokio::test] +async fn gate_catalog_replacement_during_acceptance_settles_the_turn_exactly_once() { + let server = spawn_chat_server(&["model-a"]).await; + let mut socket = connect_chat(&server.ws_base).await; + let session = launch_chat(&mut socket).await; + let token = next_wait_token(&mut socket).await; + + let state = server.state.clone(); + server + .state + .agents() + .deliver_input_after_acceptance_for_test( + &session, + InputResponse { + token, + text: "accepted during replacement".to_owned(), + }, + move || { + state + .catalog() + .publish(vec![json!({"id": "model-b", "object": "model"})]); + state.menu().reconcile_catalog_for_test(); + state + .menu() + .set_selected("model-b") + .expect("the replacement model becomes selected"); + }, + ) + .expect("the launched session remains registered") + .expect("the accepted input resumes its original run"); + + let mut errors = Vec::new(); + let mut accepted_events = 0; + let mut retired_wait = None; + let mut retired_wait_cancelled = false; + let fresh = tokio::time::timeout(Duration::from_secs(10), async { + loop { + let frame = socket.recv_json().await; + match frame["type"].as_str() { + Some("error") => errors.push(frame), + Some("agent_event") + if frame["event"]["content"] == "accepted during replacement" => + { + accepted_events += 1; + } + Some("input_required") if retired_wait_cancelled => { + break frame["token"] + .as_str() + .expect("the replacement wait carries its token") + .to_owned(); + } + Some("input_required") => { + retired_wait = frame["token"].as_str().map(str::to_owned); + } + Some("input_cancelled") => { + assert_eq!( + frame["token"].as_str(), + retired_wait.as_deref(), + "catalog retirement cancels only the old run's wait" + ); + retired_wait_cancelled = true; + } + _ => {} + } + } + }) + .await + .expect("the replacement relaunch returns to input"); + assert_eq!(errors.len(), 1, "the raced turn surfaces one failure"); + assert!( + errors[0]["message"] + .as_str() + .is_some_and(|message| message.contains("Model turn failed")), + "the failure names the model boundary: {}", + errors[0] + ); + assert_eq!(accepted_events, 1, "accepted input is retained once"); + assert_eq!( + server + .captured + .lock() + .expect("the capture lock is healthy") + .len(), + 0, + "the retired binding cannot dispatch against either generation" + ); + + answer(&mut socket, &fresh, "after replacement").await; + let second = collect_turn(&mut socket).await; + assert_eq!(delta_text(&second), "echo:after replacement"); + { + let requests = server.captured.lock().expect("the capture lock is healthy"); + assert_eq!(requests.len(), 1, "the recovery dispatch runs exactly once"); + assert_eq!(requests[0]["model"], "model-b"); + assert_eq!( + role_content_pairs(&requests[0]), + vec![ + pair("user", "accepted during replacement"), + pair("user", "after replacement"), + ], + "the replacement relaunch retains the failed input exactly once" + ); + } + socket.close().await; +} diff --git a/crates/workshop-server/tests/it/chat_gate/overload.rs b/crates/workshop-server/tests/it/chat_gate/overload.rs new file mode 100644 index 00000000..cda646e5 --- /dev/null +++ b/crates/workshop-server/tests/it/chat_gate/overload.rs @@ -0,0 +1,46 @@ +/// GATE 5 - turn-cancel. Current-chat behavior: the stop button kills +/// generation mid-stream without an error, and the chat is immediately +/// usable again. +#[tokio::test] +async fn gate_cancel_mid_generation_returns_to_waiting_and_next_input_works() { + let server = spawn_chat_server(&["test-model"]).await; + let mut socket = connect_chat(&server.ws_base).await; + let _session = launch_chat(&mut socket).await; + + let token = next_wait_token(&mut socket).await; + answer(&mut socket, &token, "hang").await; + // Generation is provably live: a text chunk of the never-finishing + // stream has reached the wire. + let delta = socket + .recv_until(Duration::from_secs(10), |frame| { + assert_ne!( + frame["type"], "error", + "the hanging turn is not an error: {frame}" + ); + frame["type"] == "agent_delta" && frame["kind"] == "text" + }) + .await; + assert_eq!(delta["content"], "nev"); + + socket.send_json(&json!({ "type": "cancel" })).await; + + // Cancellation is a stop reason: the relaunched run returns to + // waiting, and next_wait_token refuses error frames on the way - + // which asserts exactly the no-error contract. + let fresh = next_wait_token(&mut socket).await; + assert_ne!(fresh, token, "the relaunched run opens a fresh wait"); + answer(&mut socket, &fresh, "after cancel").await; + let turn = collect_turn(&mut socket).await; + assert_eq!( + delta_text(&turn), + "echo:after cancel", + "the next input after a mid-generation cancel runs a full turn" + ); + assert!( + turn.events + .iter() + .all(|event| event["event"]["content"] != "echo:hang"), + "the cancelled generation never completes into a reply" + ); + socket.close().await; +} diff --git a/crates/workshop-server/tests/it/chat_gate/protocol.rs b/crates/workshop-server/tests/it/chat_gate/protocol.rs new file mode 100644 index 00000000..8cd6d53c --- /dev/null +++ b/crates/workshop-server/tests/it/chat_gate/protocol.rs @@ -0,0 +1,54 @@ +/// GATE 2 - live streaming. Current-chat behavior: while the model +/// generates, the client sees answer text and reasoning arrive as live +/// chunks, and the completed reply supersedes them under the same id. +#[tokio::test] +async fn gate_streaming_delivers_text_and_reasoning_deltas_then_the_reply() { + let server = spawn_chat_server(&["test-model"]).await; + let mut socket = connect_chat(&server.ws_base).await; + let _session = launch_chat(&mut socket).await; + + let token = next_wait_token(&mut socket).await; + answer(&mut socket, &token, "ping").await; + let turn = collect_turn(&mut socket).await; + + let reasoning: String = turn + .deltas + .iter() + .filter(|delta| delta["kind"] == "reasoning") + .filter_map(|delta| delta["content"].as_str()) + .collect(); + assert_eq!( + reasoning, "mm", + "reasoning streams live on its own side channel during generation" + ); + assert!( + turn.deltas + .iter() + .filter(|delta| delta["kind"] == "text") + .count() + >= 2, + "the mock splits content, so generation provably streams in chunks" + ); + assert_eq!( + delta_text(&turn), + "echo:ping", + "the live text chunks assemble the reply" + ); + + let reply = turn + .events + .last() + .expect("the turn ends with its reply event"); + assert_eq!(reply["event"]["kind"], "agent_message"); + assert_eq!( + reply["event"]["content"], "echo:ping", + "the completed reply arrives after the deltas it supersedes" + ); + assert!( + turn.deltas + .iter() + .all(|delta| delta["reply"] == reply["reply"]), + "deltas and the completed reply share the superseding id" + ); + socket.close().await; +} diff --git a/crates/workshop-server/tests/it/chat_gate/recovery.rs b/crates/workshop-server/tests/it/chat_gate/recovery.rs new file mode 100644 index 00000000..0e3dd71b --- /dev/null +++ b/crates/workshop-server/tests/it/chat_gate/recovery.rs @@ -0,0 +1,305 @@ +#[tokio::test] +async fn a_live_chat_session_restarts_on_the_replacement_port_and_key() { + let server = spawn_chat_server(&["test-model"]).await; + let mut socket = connect_chat(&server.ws_base).await; + let _session = launch_chat(&mut socket).await; + let original_wait = next_wait_token(&mut socket).await; + + let replacement_captured = CapturedRequests::default(); + let captured = Arc::clone(&replacement_captured); + let replacement = spawn_gateway(Router::new().route( + "/v1/chat/completions", + post(move |headers: axum::http::HeaderMap, body: String| { + let captured = Arc::clone(&captured); + async move { + if headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + != Some("Bearer replacement-key") + { + return StatusCode::UNAUTHORIZED.into_response(); + } + gate_completions(&captured, &body) + } + }), + )) + .await; + let port = url::Url::parse(&replacement) + .expect("the replacement URL parses") + .port() + .expect("the replacement URL carries a port"); + let validated = shared_sidecar::ValidatedConnection::validate_for_test( + shared_sidecar::ConnectionFile { + port, + api_key: "replacement-key".to_owned(), + pid: std::process::id(), + epoch: 1_757_000_000, + version: "test".to_owned(), + started_at: "2026-09-07T14:14:31Z".to_owned(), + }, + ) + .expect("the replacement validates"); + gateway_updater(&server.state) + .replace_sidecar(&validated) + .expect("the replacement publishes"); + + let replacement_wait = next_wait_token(&mut socket).await; + assert_ne!( + replacement_wait, original_wait, + "the endpoint generation retires and relaunches the waiting agent" + ); + answer(&mut socket, &replacement_wait, "after gateway recovery").await; + let turn = collect_turn(&mut socket).await; + assert_eq!(delta_text(&turn), "echo:after gateway recovery"); + assert!( + server + .captured + .lock() + .expect("the original capture lock is healthy") + .is_empty(), + "the old endpoint receives no post-publication completion" + ); + assert_eq!( + replacement_captured + .lock() + .expect("the replacement capture lock is healthy") + .len(), + 1, + "the replacement endpoint and bearer complete the next turn" + ); + socket.close().await; +} + +/// GATE 4 - restart. Current-chat behavior it replaces: a conversation +/// does not die with its process. The persisted JSONL alone restores it, +/// and the relaunched agent resumes waiting for input - the supervisor's +/// own relaunch shape driven with the log reloaded from disk. +#[tokio::test] +async fn gate_restart_reloads_the_jsonl_and_resumes_waiting_for_input() { + let server = spawn_chat_server(&["test-model"]).await; + let mut socket = connect_chat(&server.ws_base).await; + let session = launch_chat(&mut socket).await; + + let token = next_wait_token(&mut socket).await; + answer(&mut socket, &token, "ping").await; + let live = collect_turn(&mut socket).await; + assert_eq!(delta_text(&live), "echo:ping"); + socket.close().await; + assert!( + server.state.agents().close(&session), + "the session ends; only the JSONL survives" + ); + + let log_path = server + .dir + .path() + .join("sessions") + .join(format!("{session}.jsonl")); + let restored = + Arc::new(WorkshopObserver::load_from(&log_path).expect("the persisted JSONL reloads")); + assert_eq!( + restored.len(), + 4, + "the whole conversation restores: input, tool result, thinking, reply" + ); + assert_eq!( + restored.get(0).map(|event| event.content), + Some("ping".to_owned()) + ); + assert_eq!( + restored.get(3).map(|event| event.content), + Some("echo:ping".to_owned()) + ); + + let mut relaunch = spawn_restored_chat(&restored, &session, &server.gateway_url); + + // The relaunched agent resumes waiting: its first act is user_input. + let frame = tokio::time::timeout(Duration::from_secs(10), relaunch.frames.recv()) + .await + .expect("the relaunched agent asks for input") + .expect("the frames channel is open"); + let InputFrame::Required { token } = frame else { + panic!("the relaunched agent must open a wait, got {frame:?}"); + }; + + // Answering proves the conversation itself was restored: the next + // round shows the model the old exchange plus the new input. + let mut entries = restored.subscribe(); + deliver_input_response( + restored.as_ref(), + &relaunch.waits, + &session, + "chat", + InputResponse { + token, + text: "and back".to_owned(), + }, + ) + .expect("the wait completes"); + let reply = tokio::time::timeout(Duration::from_secs(10), async { + loop { + let event = entries.recv().await.expect("the log broadcast stays open"); + if event.kind == RuntimeEventKind::AssistantReply { + break event; + } + } + }) + .await + .expect("the restarted agent completes a round"); + assert_eq!(reply.content, "echo:and back"); + { + let requests = server.captured.lock().expect("the capture lock is healthy"); + assert_eq!(requests.len(), 2); + assert_eq!( + role_content_pairs(&requests[1]), + vec![ + pair("user", "ping"), + pair("assistant", "echo:ping"), + pair("user", "and back"), + ], + "the reloaded JSONL alone rebuilt the conversation the model sees" + ); + } + + // Teardown: the loop is back on user_input; cancellation ends it. + relaunch.cancel.cancel(); + let result = relaunch.run.await.expect("the relaunched run joins"); + assert!( + matches!(result, Err(AgentError::Interrupted)), + "cancellation ends the relaunched run cleanly, got {result:?}" + ); +} + +/// GATE 6 - error survival. Current-chat behavior: a failed completion +/// surfaces an error to the operator and the chat keeps working - the +/// behavior that replaces the relay's gateway-health short-circuit. +#[tokio::test] +async fn gate_model_failure_surfaces_an_error_and_the_next_input_works() { + let server = spawn_chat_server(&["test-model"]).await; + let mut socket = connect_chat(&server.ws_base).await; + let _session = launch_chat(&mut socket).await; + + let token = next_wait_token(&mut socket).await; + answer(&mut socket, &token, "fail").await; + let error = socket + .recv_until(Duration::from_secs(10), |frame| frame["type"] == "error") + .await; + assert!( + error["message"] + .as_str() + .is_some_and(|message| message.contains("Model turn failed")), + "the failed model call surfaces as an error frame naming the boundary: {error}" + ); + + // The pcall'd failure never kills the program: the loop returns to + // user_input and the next turn is a normal one. + let fresh = next_wait_token(&mut socket).await; + answer(&mut socket, &fresh, "recovered").await; + let turn = collect_turn(&mut socket).await; + assert_eq!( + delta_text(&turn), + "echo:recovered", + "the next input still works after the failure" + ); + let reply = turn.events.last().expect("the recovery turn completes"); + assert_eq!(reply["event"]["content"], "echo:recovered"); + socket.close().await; +} + +/// GATE 7 - selection-loss recovery. A selection can vanish after the +/// browser accepted an input but before the built-in reads its fresh +/// `ui()` snapshot. The missing binding is a failed model turn, not a +/// silent pcall: one error reaches the socket, no request reaches the +/// gateway, and the loop accepts a recovery input. +#[tokio::test] +async fn gate_binding_loss_surfaces_one_error_and_recovers_after_selection() { + let server = spawn_chat_server(&["test-model"]).await; + let mut socket = connect_chat(&server.ws_base).await; + let session = launch_chat(&mut socket).await; + + let token = next_wait_token(&mut socket).await; + let state = server.state.clone(); + server + .state + .agents() + .deliver_input_after_acceptance_for_test( + &session, + InputResponse { + token, + text: "accepted before loss".to_owned(), + }, + move || { + state.catalog().publish(Vec::new()); + state.menu().reconcile_catalog_for_test(); + }, + ) + .expect("the launched session remains registered") + .expect("the submitted input completes its live wait"); + + let mut errors = Vec::new(); + let fresh = tokio::time::timeout(Duration::from_secs(10), async { + loop { + let frame = socket.recv_json().await; + match frame["type"].as_str() { + Some("error") => errors.push(frame), + Some("input_required") => { + break frame["token"] + .as_str() + .expect("the recovery wait carries its token") + .to_owned(); + } + _ => {} + } + } + }) + .await + .expect("the failed turn returns to input"); + assert_eq!( + errors.len(), + 1, + "the failed turn produces one visible error" + ); + assert!( + errors[0]["message"] + .as_str() + .is_some_and(|message| message.contains("Model turn failed")), + "the visible error names the failed model boundary: {}", + errors[0] + ); + assert_eq!( + server + .captured + .lock() + .expect("the capture lock is healthy") + .len(), + 0, + "a missing binding never reaches the gateway" + ); + + server + .state + .catalog() + .publish(vec![json!({ "id": "test-model", "object": "model" })]); + server + .state + .menu() + .set_selected("test-model") + .expect("the retained model can be selected for recovery"); + answer(&mut socket, &fresh, "recovered after selection").await; + let turn = collect_turn(&mut socket).await; + assert_eq!( + delta_text(&turn), + "echo:recovered after selection", + "the next input completes after selection becomes valid" + ); + assert_eq!( + server + .captured + .lock() + .expect("the capture lock is healthy") + .len(), + 1, + "only the recovered turn reaches the gateway" + ); + socket.close().await; +} diff --git a/crates/workshop-server/tests/it/main.rs b/crates/workshop-server/tests/it/main.rs index a1f33f2a..dbe685fd 100644 --- a/crates/workshop-server/tests/it/main.rs +++ b/crates/workshop-server/tests/it/main.rs @@ -11,4 +11,5 @@ mod chat_gate; mod heartbeat; mod observer; mod ratchet; +mod realtime_relay; mod session; diff --git a/crates/workshop-server/tests/it/realtime_relay.rs b/crates/workshop-server/tests/it/realtime_relay.rs new file mode 100644 index 00000000..5f2e2c00 --- /dev/null +++ b/crates/workshop-server/tests/it/realtime_relay.rs @@ -0,0 +1,371 @@ +//! Additive Workshop relay for Gateway Realtime transcription. + +#![expect( + clippy::expect_used, + reason = "integration-test helpers panic with the failed wire invariant" +)] + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use axum::Router; +use axum::extract::State; +use axum::extract::ws::{CloseFrame, Message, WebSocketUpgrade}; +use axum::http::{HeaderMap, StatusCode, Uri, header}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use futures_util::{SinkExt as _, StreamExt as _}; +use tokio::io::AsyncWriteExt as _; +use tokio::sync::Notify; +use tokio_tungstenite::tungstenite::client::IntoClientRequest as _; +use tokio_tungstenite::tungstenite::{Error as SocketError, Message as ClientMessage}; + +use crate::common::{RECV_TIMEOUT, TestServer, spawn_gateway}; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct UpstreamRequest { + path: String, + query: String, + has_origin: bool, + has_subprotocol: bool, +} + +#[derive(Clone, Default)] +struct UpstreamProbe { + request: Arc>>, + pings: Arc>>>, + pongs: Arc>>>, + browser_close: Arc>>, + control_seen: Arc, + close_seen: Arc, + disconnected: Arc, +} + +#[derive(Clone, Default)] +struct FixtureUpstream { + frames: Arc>, + gateway_bearer_seen: Arc, + browser_bearer_seen: Arc, +} + +fn canonical_server_frames() -> Vec { + let fixtures: serde_json::Value = serde_json::from_slice(include_bytes!( + "../../../gateway-stt/tests/fixtures/realtime/valid-sequences.json" + )) + .expect("canonical Realtime sequences parse"); + [ + "first_event_readiness", + "hypothesis_negotiation", + "overlapping_items_reverse_completion", + "clear_retires_only_uncommitted_input", + "saturated_commit_retry", + "engine_replacement", + ] + .into_iter() + .flat_map(|name| { + fixtures[name]["events"] + .as_array() + .expect("canonical sequence has events") + .iter() + .filter(|entry| entry["direction"] == "server") + .map(|entry| { + serde_json::to_string(&entry["message"]).expect("canonical event serializes") + }) + .collect::>() + }) + .collect() +} + +impl UpstreamProbe { + fn request(&self) -> UpstreamRequest { + self.request + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + .expect("the upstream handshake was recorded") + } + + fn pings(&self) -> Vec> { + self.pings + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + fn pongs(&self) -> Vec> { + self.pongs + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } + + fn browser_close(&self) -> Option<(u16, String)> { + self.browser_close + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone() + } +} + +async fn fixture_upstream( + State(fixture): State, + headers: HeaderMap, + ws: WebSocketUpgrade, +) -> Response { + let authorization = headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + fixture + .gateway_bearer_seen + .store(authorization == "Bearer test-key", Ordering::Release); + fixture + .browser_bearer_seen + .store(authorization.contains("browser-secret"), Ordering::Release); + if authorization != "Bearer test-key" { + return StatusCode::UNAUTHORIZED.into_response(); + } + ws.on_upgrade(move |mut socket| async move { + for frame in fixture.frames.iter() { + if socket + .send(Message::Text(frame.clone().into())) + .await + .is_err() + { + return; + } + } + while let Some(Ok(message)) = socket.recv().await { + match message { + Message::Text(text) => { + if socket.send(Message::Text(text)).await.is_err() { + return; + } + } + Message::Binary(bytes) => { + if socket.send(Message::Binary(bytes)).await.is_err() { + return; + } + } + Message::Close(_) => return, + Message::Ping(_) | Message::Pong(_) => {} + } + } + }) +} + +async fn upstream( + State(probe): State, + headers: HeaderMap, + uri: Uri, + ws: WebSocketUpgrade, +) -> Response { + let authorization = headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_owned(); + let has_origin = headers.contains_key(header::ORIGIN); + let has_subprotocol = headers.contains_key("sec-websocket-protocol"); + *probe + .request + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(UpstreamRequest { + path: uri.path().to_owned(), + query: uri.query().unwrap_or_default().to_owned(), + has_origin, + has_subprotocol, + }); + if authorization != "Bearer test-key" { + return StatusCode::UNAUTHORIZED.into_response(); + } + ws.on_upgrade(move |mut socket| async move { + while let Some(Ok(message)) = socket.recv().await { + match message { + Message::Text(text) => { + if socket.send(Message::Text(text)).await.is_err() { + return; + } + } + Message::Binary(bytes) => { + if socket.send(Message::Binary(bytes)).await.is_err() + || socket + .send(Message::Ping(vec![9, 8, 7].into())) + .await + .is_err() + || socket + .send(Message::Pong(vec![6, 5, 4].into())) + .await + .is_err() + { + return; + } + } + Message::Pong(bytes) => { + probe + .pongs + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(bytes.to_vec()); + probe.control_seen.notify_one(); + } + Message::Close(frame) => { + if let Some(frame) = frame { + *probe + .browser_close + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = + Some((frame.code, frame.reason.to_string())); + } + probe.close_seen.notify_one(); + return; + } + Message::Ping(bytes) => { + probe + .pings + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(bytes.to_vec()); + probe.control_seen.notify_one(); + } + } + } + probe.disconnected.notify_one(); + }) +} + +async fn spawn_probe() -> (String, UpstreamProbe) { + let probe = UpstreamProbe::default(); + let app = Router::new() + .route("/v1/realtime", get(upstream)) + .with_state(probe.clone()); + (spawn_gateway(app).await, probe) +} + +async fn recv( + socket: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, +) -> ClientMessage { + tokio::time::timeout(RECV_TIMEOUT, socket.next()) + .await + .expect("a frame arrives before the deadline") + .expect("the relay socket stays open") + .expect("the relayed frame is valid") +} + +async fn assert_no_frame( + socket: &mut tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, +) { + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), socket.next()) + .await + .is_err(), + "the terminated control frame produces no duplicate or forwarded frame" + ); +} + +async fn upstream_close(ws: WebSocketUpgrade) -> Response { + ws.on_upgrade(|mut socket| async move { + let _ = socket + .send(Message::Close(Some(CloseFrame { + code: 4101, + reason: "upstream finished".into(), + }))) + .await; + }) +} + +#[derive(Clone, Default)] +struct StalledPeerProbe { + frame_sent: Arc, + frame_sent_event: Arc, +} + +impl StalledPeerProbe { + async fn wait_for_frame(&self) { + let notified = self.frame_sent_event.notified(); + if self.frame_sent.load(Ordering::Acquire) { + return; + } + notified.await; + } +} + +async fn send_large_frame_then_disconnect( + State(probe): State, + ws: WebSocketUpgrade, +) -> Response { + ws.on_upgrade(move |mut socket| async move { + if socket + .send(Message::Binary(vec![0x5a; 32 * 1024 * 1024].into())) + .await + .is_ok() + { + probe.frame_sent.store(true, Ordering::Release); + probe.frame_sent_event.notify_one(); + } + }) +} + +async fn recovered_upstream(headers: HeaderMap, ws: WebSocketUpgrade) -> Response { + let authorized = headers + .get(header::AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + == Some("Bearer replacement-key"); + if !authorized { + return StatusCode::UNAUTHORIZED.into_response(); + } + ws.on_upgrade(|mut socket| async move { + for event in [ + r#"{"type":"session.created","session":{"id":"replacement"}}"#, + r#"{"type":"session.updated","session":{"include":["item.input_audio_transcription.hypothesis"]}}"#, + ] { + if socket.send(Message::Text(event.into())).await.is_err() { + return; + } + } + }) +} + +fn request_with( + url: &str, + origin: Option<&str>, + subprotocol: Option<&str>, +) -> tokio_tungstenite::tungstenite::http::Request<()> { + let mut request = url + .into_client_request() + .expect("the WebSocket request builds"); + if let Some(origin) = origin { + request.headers_mut().insert( + header::ORIGIN, + origin.parse().expect("the test Origin is valid"), + ); + } + if let Some(subprotocol) = subprotocol { + request.headers_mut().insert( + "sec-websocket-protocol", + subprotocol.parse().expect("the test subprotocol is valid"), + ); + } + request +} + +async fn rejected_status(request: tokio_tungstenite::tungstenite::http::Request<()>) -> StatusCode { + let error = tokio_tungstenite::connect_async(request) + .await + .expect_err("the WebSocket handshake is rejected"); + let SocketError::Http(response) = error else { + panic!("the rejection is an HTTP response, got {error:?}"); + }; + StatusCode::from_u16(response.status().as_u16()).expect("the status is standard") +} + +include!("realtime_relay/authentication.rs"); +include!("realtime_relay/protocol.rs"); +include!("realtime_relay/lifecycle.rs"); +include!("realtime_relay/recovery.rs"); +include!("realtime_relay/overload.rs"); +include!("realtime_relay/canonical_sequence.rs"); diff --git a/crates/workshop-server/tests/it/realtime_relay/authentication.rs b/crates/workshop-server/tests/it/realtime_relay/authentication.rs new file mode 100644 index 00000000..10a8abda --- /dev/null +++ b/crates/workshop-server/tests/it/realtime_relay/authentication.rs @@ -0,0 +1,115 @@ +#[tokio::test] +async fn realtime_relay_is_authenticated_fixed_and_payload_opaque() { + let (gateway, probe) = spawn_probe().await; + let server = TestServer::spawn(&gateway); + let url = server.ws_url("/v1/realtime?ignored=browser"); + let mut request = request_with(&url, None, None); + request.headers_mut().insert( + header::AUTHORIZATION, + "Bearer browser-secret" + .parse() + .expect("the browser credential is a header"), + ); + let (mut socket, response) = tokio_tungstenite::connect_async(request) + .await + .expect("the Workshop Realtime socket upgrades"); + assert_eq!(response.status(), StatusCode::SWITCHING_PROTOCOLS); + + let opaque = "not JSON: \u{00e9}\u{65e5}\u{1f40d}"; + socket + .send(ClientMessage::Text(opaque.into())) + .await + .expect("opaque text sends"); + assert_eq!(recv(&mut socket).await, ClientMessage::Text(opaque.into())); + + socket + .send(ClientMessage::Ping(vec![2, 4, 6, 8].into())) + .await + .expect("browser ping sends"); + assert_eq!( + recv(&mut socket).await, + ClientMessage::Pong(vec![2, 4, 6, 8].into()), + "the Workshop hop owns exactly one matching browser pong" + ); + assert_no_frame(&mut socket).await; + + socket + .send(ClientMessage::Pong(vec![1, 3, 5, 7].into())) + .await + .expect("caller-owned pong sends"); + + let binary = vec![0, 255, 1, 128, 2]; + socket + .send(ClientMessage::Binary(binary.clone().into())) + .await + .expect("opaque binary sends"); + assert_eq!( + recv(&mut socket).await, + ClientMessage::Binary(binary.into()) + ); + tokio::time::timeout(RECV_TIMEOUT, async { + loop { + let notified = probe.control_seen.notified(); + if !probe.pongs().is_empty() { + break; + } + notified.await; + } + }) + .await + .expect("the Gateway hop receives its automatic pong"); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + assert_eq!( + probe.pongs(), + vec![vec![9, 8, 7]], + "the Gateway hop owns exactly one matching pong and receives no browser pong" + ); + assert!( + probe.pings().is_empty(), + "the browser ping terminates at Workshop" + ); + assert_no_frame(&mut socket).await; + socket.close(None).await.expect("the browser socket closes"); + + assert_eq!( + probe.request(), + UpstreamRequest { + path: "/v1/realtime".to_owned(), + query: "intent=transcription".to_owned(), + has_origin: false, + has_subprotocol: false, + }, + "the connector fixes the upstream target and forwards no browser policy headers" + ); +} + +#[tokio::test] +async fn realtime_relay_enforces_same_origin_authority_and_no_subprotocol() { + let (gateway, _probe) = spawn_probe().await; + let server = TestServer::spawn(&gateway); + let url = server.ws_url("/v1/realtime?intent=transcription"); + let parsed = url::Url::parse(&url).expect("the Workshop URL parses"); + let authority = parsed + .socket_addrs(|| None) + .expect("the Workshop authority resolves") + .into_iter() + .next() + .expect("the Workshop authority has an address"); + let same_origin = format!("http://{authority}"); + + let (socket, response) = + tokio_tungstenite::connect_async(request_with(&url, Some(&same_origin), None)) + .await + .expect("the exact same origin upgrades"); + assert_eq!(response.status(), StatusCode::SWITCHING_PROTOCOLS); + drop(socket); + + assert_eq!( + rejected_status(request_with(&url, Some("http://localhost:9"), None)).await, + StatusCode::FORBIDDEN + ); + assert_eq!( + rejected_status(request_with(&url, Some(&same_origin), Some("realtime"))).await, + StatusCode::BAD_REQUEST + ); +} diff --git a/crates/workshop-server/tests/it/realtime_relay/canonical_sequence.rs b/crates/workshop-server/tests/it/realtime_relay/canonical_sequence.rs new file mode 100644 index 00000000..b14433b0 --- /dev/null +++ b/crates/workshop-server/tests/it/realtime_relay/canonical_sequence.rs @@ -0,0 +1,49 @@ +#[tokio::test] +async fn canonical_sequences_cross_the_fake_upstream_unchanged_without_browser_bearer() { + let fixture = FixtureUpstream { + frames: Arc::new(canonical_server_frames()), + ..FixtureUpstream::default() + }; + let gateway = spawn_gateway( + Router::new() + .route("/v1/realtime", get(fixture_upstream)) + .with_state(fixture.clone()), + ) + .await; + let server = TestServer::spawn(&gateway); + let url = server.ws_url("/v1/realtime?browser=query"); + let mut request = request_with(&url, None, None); + request.headers_mut().insert( + header::AUTHORIZATION, + "Bearer browser-secret" + .parse() + .expect("browser bearer is a header"), + ); + let (mut socket, response) = tokio_tungstenite::connect_async(request) + .await + .expect("Workshop fixture relay upgrades"); + assert_eq!(response.status(), StatusCode::SWITCHING_PROTOCOLS); + + for expected in fixture.frames.iter() { + let ClientMessage::Text(actual) = recv(&mut socket).await else { + panic!("canonical fixture remains a text payload"); + }; + assert_eq!( + serde_json::from_str::(&actual).expect("relayed event parses"), + serde_json::from_str::(expected).expect("fixture event parses") + ); + } + let opaque = "opaque: not JSON, not speech state"; + socket + .send(ClientMessage::Text(opaque.into())) + .await + .expect("opaque browser text sends"); + assert_eq!(recv(&mut socket).await, ClientMessage::Text(opaque.into())); + + assert!(fixture.gateway_bearer_seen.load(Ordering::Acquire)); + assert!( + !fixture.browser_bearer_seen.load(Ordering::Acquire), + "the browser bearer never reaches the fake Gateway" + ); + socket.close(None).await.expect("fixture socket closes"); +} diff --git a/crates/workshop-server/tests/it/realtime_relay/lifecycle.rs b/crates/workshop-server/tests/it/realtime_relay/lifecycle.rs new file mode 100644 index 00000000..a3b137ec --- /dev/null +++ b/crates/workshop-server/tests/it/realtime_relay/lifecycle.rs @@ -0,0 +1,27 @@ +#[tokio::test] +async fn browser_disconnect_releases_the_gateway_peer() { + let (gateway, probe) = spawn_probe().await; + let server = TestServer::spawn(&gateway); + let (mut socket, _) = + tokio_tungstenite::connect_async(server.ws_url("/v1/realtime?intent=transcription")) + .await + .expect("the Workshop Realtime socket upgrades"); + let close_seen = probe.close_seen.notified(); + let disconnected = probe.disconnected.notified(); + let tokio_tungstenite::MaybeTlsStream::Plain(transport) = socket.get_mut() else { + panic!("the loopback Workshop test uses a plain transport"); + }; + transport + .shutdown() + .await + .expect("the browser transport disconnects"); + drop(socket); + tokio::time::timeout(RECV_TIMEOUT, async { + tokio::select! { + () = close_seen => {} + () = disconnected => {} + } + }) + .await + .expect("an abrupt browser disconnect closes the Gateway hop"); +} diff --git a/crates/workshop-server/tests/it/realtime_relay/overload.rs b/crates/workshop-server/tests/it/realtime_relay/overload.rs new file mode 100644 index 00000000..c98cb27b --- /dev/null +++ b/crates/workshop-server/tests/it/realtime_relay/overload.rs @@ -0,0 +1,27 @@ +#[tokio::test] +async fn stalled_browser_cleanup_is_bounded_after_gateway_disconnect() { + let probe = StalledPeerProbe::default(); + let gateway = spawn_gateway( + Router::new() + .route("/v1/realtime", get(send_large_frame_then_disconnect)) + .with_state(probe.clone()), + ) + .await; + let server = TestServer::spawn(&gateway); + let (mut socket, _) = + tokio_tungstenite::connect_async(server.ws_url("/v1/realtime?intent=transcription")) + .await + .expect("the Workshop Realtime socket upgrades"); + tokio::time::timeout(RECV_TIMEOUT, probe.wait_for_frame()) + .await + .expect("the Gateway fills the relay's browser send"); + + tokio::time::sleep(std::time::Duration::from_millis(750)).await; + let first = tokio::time::timeout(RECV_TIMEOUT, socket.next()) + .await + .expect("bounded relay cleanup releases the stalled browser"); + assert!( + !matches!(first, Some(Ok(ClientMessage::Binary(_)))), + "the stalled send is canceled before peer reads can release it" + ); +} diff --git a/crates/workshop-server/tests/it/realtime_relay/protocol.rs b/crates/workshop-server/tests/it/realtime_relay/protocol.rs new file mode 100644 index 00000000..9d802f23 --- /dev/null +++ b/crates/workshop-server/tests/it/realtime_relay/protocol.rs @@ -0,0 +1,59 @@ +#[tokio::test] +async fn workshop_exposes_only_the_realtime_speech_route() { + let (gateway, _probe) = spawn_probe().await; + let server = TestServer::spawn(&gateway); + let client = reqwest::Client::new(); + for path in ["/stt", "/stt/capability"] { + let response = client + .get(server.http_url(path)) + .send() + .await + .expect("the Workshop route answers"); + assert_eq!( + response.status(), + StatusCode::NOT_FOUND, + "GET {path} is retired" + ); + } +} + +#[tokio::test] +async fn gateway_close_code_and_reason_reach_the_browser() { + let gateway = spawn_gateway(Router::new().route("/v1/realtime", get(upstream_close))).await; + let server = TestServer::spawn(&gateway); + let (mut socket, _) = + tokio_tungstenite::connect_async(server.ws_url("/v1/realtime?intent=transcription")) + .await + .expect("the Workshop Realtime socket upgrades"); + let ClientMessage::Close(Some(close)) = recv(&mut socket).await else { + panic!("the upstream close frame is relayed"); + }; + assert_eq!(u16::from(close.code), 4101); + assert_eq!(close.reason, "upstream finished"); +} + +#[tokio::test] +async fn browser_close_code_and_reason_reach_the_gateway() { + let (gateway, probe) = spawn_probe().await; + let server = TestServer::spawn(&gateway); + let (mut socket, _) = + tokio_tungstenite::connect_async(server.ws_url("/v1/realtime?intent=transcription")) + .await + .expect("the Workshop Realtime socket upgrades"); + socket + .send(ClientMessage::Close(Some( + tokio_tungstenite::tungstenite::protocol::CloseFrame { + code: 4201.into(), + reason: "browser finished".into(), + }, + ))) + .await + .expect("the browser close sends"); + tokio::time::timeout(RECV_TIMEOUT, probe.close_seen.notified()) + .await + .expect("the gateway receives the close"); + assert_eq!( + probe.browser_close(), + Some((4201, "browser finished".to_owned())) + ); +} diff --git a/crates/workshop-server/tests/it/realtime_relay/recovery.rs b/crates/workshop-server/tests/it/realtime_relay/recovery.rs new file mode 100644 index 00000000..acfc8094 --- /dev/null +++ b/crates/workshop-server/tests/it/realtime_relay/recovery.rs @@ -0,0 +1,33 @@ +#[tokio::test] +async fn browser_realtime_retry_reaches_the_new_port_and_key_without_workshop_reload() { + let server = TestServer::spawn("http://127.0.0.1:1"); + let url = server.ws_url("/v1/realtime?intent=transcription"); + assert_eq!( + rejected_status(request_with(&url, None, None)).await, + StatusCode::BAD_GATEWAY, + "the dead original sidecar produces the recoverable handshake failure" + ); + + let replacement = + spawn_gateway(Router::new().route("/v1/realtime", get(recovered_upstream))).await; + server.replace_gateway(&replacement, "replacement-key"); + + let (mut socket, response) = tokio_tungstenite::connect_async(request_with(&url, None, None)) + .await + .expect("the browser retry upgrades through the same Workshop server"); + assert_eq!(response.status(), StatusCode::SWITCHING_PROTOCOLS); + let ClientMessage::Text(created) = recv(&mut socket).await else { + panic!("the replacement readiness frame stays text"); + }; + assert_eq!( + serde_json::from_str::(&created).expect("readiness parses")["type"], + "session.created" + ); + let ClientMessage::Text(updated) = recv(&mut socket).await else { + panic!("the replacement negotiation frame stays text"); + }; + assert_eq!( + serde_json::from_str::(&updated).expect("negotiation parses")["type"], + "session.updated" + ); +} diff --git a/crates/workshop-server/ui/pcm-worklet.js b/crates/workshop-server/ui/pcm-worklet.js index 9364ca2a..a4092517 100644 --- a/crates/workshop-server/ui/pcm-worklet.js +++ b/crates/workshop-server/ui/pcm-worklet.js @@ -1,20 +1,65 @@ "use strict"; -// Ships each mono f32 PCM block to the page, which forwards it over the -// /stt WebSocket. Runs on the audio rendering thread inside an -// AudioContext constructed at 16 kHz, so blocks arrive already resampled. -class PcmCaptureProcessor extends AudioWorkletProcessor { +const OUTPUT_SAMPLE_RATE = 24_000; +const DEFAULT_CHUNK_SAMPLES = OUTPUT_SAMPLE_RATE / 10; + +// Converts the first input channel into exact little-endian mono PCM16. +// Full 100 ms chunks cross to the page immediately. A final partial chunk +// stays owned here until the page requests a flush before stopping. +class Pcm16CaptureProcessor extends AudioWorkletProcessor { + constructor(options) { + super(); + if (sampleRate !== OUTPUT_SAMPLE_RATE) { + throw new Error(`pcm16-capture requires a 24 kHz AudioContext, received ${sampleRate} Hz`); + } + const requested = options && options.processorOptions && options.processorOptions.chunkSamples; + this.chunkSamples = + Number.isSafeInteger(requested) && requested > 0 ? requested : DEFAULT_CHUNK_SAMPLES; + this.pending = new ArrayBuffer(this.chunkSamples * 2); + this.pendingView = new DataView(this.pending); + this.pendingSamples = 0; + this.port.onmessage = (event) => { + const type = event && event.data && event.data.type; + if (type === "clear") { + this.pendingSamples = 0; + } else if (type === "flush") { + this.flush(); + this.port.postMessage({ type: "flushed" }); + } + }; + } + + emit(samples) { + const bytes = samples * 2; + const output = + samples === this.chunkSamples ? this.pending : this.pending.slice(0, bytes); + this.port.postMessage(output, [output]); + this.pending = new ArrayBuffer(this.chunkSamples * 2); + this.pendingView = new DataView(this.pending); + this.pendingSamples = 0; + } + + flush() { + if (this.pendingSamples > 0) { + this.emit(this.pendingSamples); + } + } + process(inputs) { const channel = inputs[0] && inputs[0][0]; if (channel && channel.length > 0) { - // The engine reuses its input buffers, so the block is copied before - // crossing to the main thread. - const copy = new Float32Array(channel); - this.port.postMessage(copy.buffer, [copy.buffer]); + for (let index = 0; index < channel.length; index += 1) { + const sample = Math.max(-1, Math.min(1, channel[index])); + const pcm = Math.round(sample < 0 ? sample * 0x8000 : sample * 0x7fff); + this.pendingView.setInt16(this.pendingSamples * 2, pcm, true); + this.pendingSamples += 1; + if (this.pendingSamples === this.chunkSamples) { + this.emit(this.chunkSamples); + } + } } - // No output is written; the node renders silence into the graph. return true; } } -registerProcessor("pcm-capture", PcmCaptureProcessor); +registerProcessor("pcm16-capture", Pcm16CaptureProcessor); diff --git a/crates/workshop-server/ui/src/main.ts b/crates/workshop-server/ui/src/main.ts index 860798b2..209745e0 100644 --- a/crates/workshop-server/ui/src/main.ts +++ b/crates/workshop-server/ui/src/main.ts @@ -6,6 +6,7 @@ import { createToastStack } from "shared-ui/toast"; import { DisposableStore, toDisposable } from "./base/lifecycle"; import { ModelService } from "./services/model-service"; +import { SpeechCaptureService } from "./services/speech-capture"; import { UpdateService } from "./services/update-service"; import { WorkbenchService } from "./services/workbench-service"; import { WorkshopSocket } from "./services/workshop-socket"; @@ -78,6 +79,7 @@ const modelService = disposables.add( // progress, chat gating - lives in the WorkbenchService, fed from the // same snapshots. The Model menu's Profiles section reads it below. const workbenchService = disposables.add(new WorkbenchService()); +const speechCapture = new SpeechCaptureService(); disposables.add(workshopSocket.onStatus((frame) => statusBar.render(frame))); // A dropped socket means every in-flight status is stale; the bar returns @@ -107,7 +109,8 @@ const dock = createDockview(dockEl, { // (add and remove folders) can announce their outcomes; the model // service rides along so the agent session's toolbar picker reads the // shared catalog and selection. - createComponent: (options) => createPanelComponent(options, { statusBar, modelService }), + createComponent: (options) => + createPanelComponent(options, { statusBar, modelService, speechCapture }), createTabComponent: createPanelTabComponent, theme: themeDark, disableFloatingGroups: true, @@ -116,6 +119,7 @@ const dock = createDockview(dockEl, { noPanelsOverlay: "emptyGroup", }); disposables.add(dock); +disposables.add(speechCapture); disposables.add(initZones(dock)); // Restore the persisted layout; any failure falls back to the known-good diff --git a/crates/workshop-server/ui/src/services/protocol.ts b/crates/workshop-server/ui/src/services/protocol.ts index 5183ef5e..5c4f8837 100644 --- a/crates/workshop-server/ui/src/services/protocol.ts +++ b/crates/workshop-server/ui/src/services/protocol.ts @@ -1,7 +1,7 @@ // The pure wire types of the workshop protocol: the JSON frame and payload -// shapes exchanged with the server over /ws, /agents/ws, /stt, and -// /v1/models. Types only - the socket logic that sends and routes these -// frames stays in workshop-socket.ts, agent-socket.ts, and ui/stt.ts. The +// shapes exchanged with the server over /ws, /agents/ws, and /v1/models. +// Types only - the socket logic that sends and routes these frames stays +// in workshop-socket.ts and agent-socket.ts. The // Rust half of this contract is // crates/workshop-server/src/protocol.rs; the two files // cross-cite each other so a shape change touches both or neither. The @@ -251,39 +251,3 @@ export interface InputResponseFrame { export interface AgentCancelFrame { type: "cancel"; } - -/** - * The /stt announcement that a `start` began a new stream generation, - * sent before any of that generation's interim or final frames. - * Generations count from 1 per connection; the client tracks the current - * one and discards frames a stop/restart race left behind from a - * superseded take. - */ -export interface StreamFrame { - type: "stream"; - generation: number; -} - -/** - * One interim transcription push on /stt: the take's crystallized - * committed prefix (append-only within a take) plus the interim model's - * decode of the audio past it, tagged with the take's stream generation. - */ -export interface InterimFrame { - type: "interim"; - committed: string; - tentative: string; - generation: number; -} - -/** - * The take's single stop reply on /stt: the assembled transcript plus - * the total PCM frames received since the take's start, tagged with the - * take's stream generation. - */ -export interface FinalFrame { - type: "final"; - text: string; - frames: number; - generation: number; -} diff --git a/crates/workshop-server/ui/src/services/realtime-event-decoder.ts b/crates/workshop-server/ui/src/services/realtime-event-decoder.ts new file mode 100644 index 00000000..7bcc122f --- /dev/null +++ b/crates/workshop-server/ui/src/services/realtime-event-decoder.ts @@ -0,0 +1,393 @@ +const HYPOTHESIS_INCLUDE = "item.input_audio_transcription.hypothesis"; + +interface RealtimeAudioFormat { + readonly type: "audio/pcm"; + readonly rate: 24000; +} + +interface RealtimeTranscriptionConfiguration { + readonly model: "realtime-transcribe"; + readonly prompt: string; +} + +interface RealtimeEffectiveSession { + readonly id: string; + readonly object: "realtime.transcription_session"; + readonly type: "transcription"; + readonly audio: { + readonly input: { + readonly format: RealtimeAudioFormat; + readonly noise_reduction: null; + readonly transcription: RealtimeTranscriptionConfiguration; + readonly turn_detection: null; + }; + }; + readonly include: readonly [] | readonly [typeof HYPOTHESIS_INCLUDE]; +} + +interface RealtimeWireError { + readonly type: string; + readonly code: string; + readonly message: string; + readonly param?: string | null; + readonly event_id?: string | null; +} + +interface RealtimeConversationItem { + readonly id: string; + readonly type: "message"; + readonly status: "completed"; + readonly role: "user"; + readonly content: readonly [ + { + readonly type: "input_audio"; + readonly transcript: null; + }, + ]; +} + +interface RealtimeDurationUsage { + readonly type: "duration"; + readonly seconds: number; +} + +/** A fully validated server event from the Realtime transcription wire. */ +export type RealtimeEvent = + | { + readonly type: "session.created"; + readonly event_id: string; + readonly session: RealtimeEffectiveSession; + } + | { + readonly type: "session.updated"; + readonly event_id: string; + readonly session: RealtimeEffectiveSession; + } + | { + readonly type: "input_audio_buffer.committed"; + readonly event_id: string; + readonly item_id: string; + readonly previous_item_id: string | null; + } + | { + readonly type: "input_audio_buffer.cleared"; + readonly event_id: string; + } + | { + readonly type: "conversation.item.created"; + readonly event_id: string; + readonly previous_item_id: string | null; + readonly item: RealtimeConversationItem; + } + | { + readonly type: "conversation.item.input_audio_transcription.delta"; + readonly event_id: string; + readonly item_id: string; + readonly content_index: 0; + readonly delta: string; + } + | { + readonly type: "conversation.item.input_audio_transcription.completed"; + readonly event_id: string; + readonly item_id: string; + readonly content_index: 0; + readonly transcript: string; + readonly usage: RealtimeDurationUsage; + } + | { + readonly type: "conversation.item.input_audio_transcription.failed"; + readonly event_id: string; + readonly item_id: string; + readonly content_index: 0; + readonly error: Omit; + } + | { + readonly type: "conversation.item.input_audio_transcription.hypothesis"; + readonly event_id: string; + readonly item_id: string; + readonly content_index: 0; + readonly revision: number; + readonly transcript: string; + readonly finalized: string; + readonly agreed: string; + readonly tentative: string; + readonly audio_start_ms: number; + readonly audio_end_ms: number; + } + | { + readonly type: "error"; + readonly event_id: string; + readonly error: RealtimeWireError; + }; + +function exactRecord( + value: unknown, + required: readonly string[], + optional: readonly string[] = [], +): value is Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + const allowed = new Set([...required, ...optional]); + const keys = Reflect.ownKeys(value); + return ( + required.every((field) => Object.hasOwn(value, field)) && + keys.every((field) => typeof field === "string" && allowed.has(field)) + ); +} + +function nonemptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function nullableId(value: unknown): value is string | null { + return value === null || nonemptyString(value); +} + +function unsignedSafeInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) >= 0; +} + +function effectiveSession(value: unknown): value is RealtimeEffectiveSession { + if ( + !exactRecord(value, ["id", "object", "type", "audio", "include"]) || + !nonemptyString(value.id) || + value.object !== "realtime.transcription_session" || + value.type !== "transcription" || + !Array.isArray(value.include) || + !( + value.include.length === 0 || + (value.include.length === 1 && value.include[0] === HYPOTHESIS_INCLUDE) + ) || + !exactRecord(value.audio, ["input"]) + ) { + return false; + } + const input = value.audio.input; + if ( + !exactRecord(input, [ + "format", + "noise_reduction", + "transcription", + "turn_detection", + ]) || + input.noise_reduction !== null || + input.turn_detection !== null || + !exactRecord(input.format, ["type", "rate"]) || + input.format.type !== "audio/pcm" || + input.format.rate !== 24000 || + !exactRecord(input.transcription, ["model", "prompt"]) || + input.transcription.model !== "realtime-transcribe" || + typeof input.transcription.prompt !== "string" + ) { + return false; + } + return true; +} + +function wireError(value: unknown, allowEventId: boolean): value is RealtimeWireError { + const optional = allowEventId ? ["param", "event_id"] : ["param"]; + if ( + !exactRecord(value, ["type", "code", "message"], optional) || + !nonemptyString(value.type) || + !nonemptyString(value.code) || + !nonemptyString(value.message) + ) { + return false; + } + if (Object.hasOwn(value, "param") && !nullableId(value.param)) { + return false; + } + return !Object.hasOwn(value, "event_id") || nullableId(value.event_id); +} + +function conversationItem(value: unknown): value is RealtimeConversationItem { + if ( + !exactRecord(value, ["id", "type", "status", "role", "content"]) || + !nonemptyString(value.id) || + value.type !== "message" || + value.status !== "completed" || + value.role !== "user" || + !Array.isArray(value.content) || + value.content.length !== 1 + ) { + return false; + } + const content = value.content[0]; + return ( + exactRecord(content, ["type", "transcript"]) && + content.type === "input_audio" && + content.transcript === null + ); +} + +function durationUsage(value: unknown): value is RealtimeDurationUsage { + return ( + exactRecord(value, ["type", "seconds"]) && + value.type === "duration" && + typeof value.seconds === "number" && + Number.isFinite(value.seconds) && + value.seconds >= 0 + ); +} + +function transcriptionBase( + value: Record, + fields: readonly string[], +): boolean { + return ( + exactRecord(value, fields) && + nonemptyString(value.event_id) && + nonemptyString(value.item_id) && + value.content_index === 0 + ); +} + +/** + * Validates an unknown Realtime server value without side effects. + * Unsupported types and malformed event shapes return null. + */ +export function decodeRealtimeEvent(value: unknown): RealtimeEvent | null { + if ( + typeof value !== "object" || + value === null || + Array.isArray(value) || + typeof (value as Record).type !== "string" + ) { + return null; + } + const event = value as Record; + switch (event.type) { + case "session.created": + case "session.updated": + if ( + exactRecord(event, ["event_id", "type", "session"]) && + nonemptyString(event.event_id) && + effectiveSession(event.session) + ) { + return event as RealtimeEvent; + } + return null; + case "input_audio_buffer.committed": + if ( + exactRecord(event, [ + "event_id", + "type", + "item_id", + "previous_item_id", + ]) && + nonemptyString(event.event_id) && + nonemptyString(event.item_id) && + nullableId(event.previous_item_id) + ) { + return event as RealtimeEvent; + } + return null; + case "input_audio_buffer.cleared": + if ( + exactRecord(event, ["event_id", "type"]) && + nonemptyString(event.event_id) + ) { + return event as RealtimeEvent; + } + return null; + case "conversation.item.created": + if ( + exactRecord(event, [ + "event_id", + "type", + "previous_item_id", + "item", + ]) && + nonemptyString(event.event_id) && + nullableId(event.previous_item_id) && + conversationItem(event.item) + ) { + return event as RealtimeEvent; + } + return null; + case "conversation.item.input_audio_transcription.delta": + if ( + transcriptionBase(event, [ + "event_id", + "type", + "item_id", + "content_index", + "delta", + ]) && + typeof event.delta === "string" + ) { + return event as RealtimeEvent; + } + return null; + case "conversation.item.input_audio_transcription.completed": + if ( + transcriptionBase(event, [ + "event_id", + "type", + "item_id", + "content_index", + "transcript", + "usage", + ]) && + typeof event.transcript === "string" && + durationUsage(event.usage) + ) { + return event as RealtimeEvent; + } + return null; + case "conversation.item.input_audio_transcription.failed": + if ( + transcriptionBase(event, [ + "event_id", + "type", + "item_id", + "content_index", + "error", + ]) && + wireError(event.error, false) + ) { + return event as RealtimeEvent; + } + return null; + case "conversation.item.input_audio_transcription.hypothesis": + if ( + transcriptionBase(event, [ + "event_id", + "type", + "item_id", + "content_index", + "revision", + "transcript", + "finalized", + "agreed", + "tentative", + "audio_start_ms", + "audio_end_ms", + ]) && + unsignedSafeInteger(event.revision) && + typeof event.transcript === "string" && + typeof event.finalized === "string" && + typeof event.agreed === "string" && + typeof event.tentative === "string" && + event.transcript === `${event.finalized}${event.agreed}${event.tentative}` && + unsignedSafeInteger(event.audio_start_ms) && + unsignedSafeInteger(event.audio_end_ms) && + event.audio_start_ms <= event.audio_end_ms + ) { + return event as RealtimeEvent; + } + return null; + case "error": + if ( + exactRecord(event, ["event_id", "type", "error"]) && + nonemptyString(event.event_id) && + wireError(event.error, true) + ) { + return event as RealtimeEvent; + } + return null; + default: + return null; + } +} diff --git a/crates/workshop-server/ui/src/services/realtime-transcription.ts b/crates/workshop-server/ui/src/services/realtime-transcription.ts new file mode 100644 index 00000000..1947ef54 --- /dev/null +++ b/crates/workshop-server/ui/src/services/realtime-transcription.ts @@ -0,0 +1,316 @@ +import { Emitter, type Event as ServiceEvent } from "../base/event"; +import { Disposable } from "../base/lifecycle"; +import { + decodeRealtimeEvent, + type RealtimeEvent, +} from "./realtime-event-decoder"; + +const HYPOTHESIS_INCLUDE = "item.input_audio_transcription.hypothesis"; +const RECONNECT_INITIAL_MS = 1000; +const RECONNECT_MAX_MS = 30_000; + +/** Readiness of the browser's Realtime transcription connection. */ +export type RealtimeTranscriptionState = "connecting" | "ready" | "unavailable"; + +/** A recoverable transport or server-event decoding failure. */ +export interface RealtimeTranscriptionError { + readonly code: string; + readonly scope: "connection" | "session"; + readonly eventId: string | null; + readonly recoverable: true; +} + +/** The WebSocket surface used by the DOM-free Realtime service. */ +export interface RealtimeSocket { + readonly readyState: number; + addEventListener?( + type: "open" | "message" | "error" | "close", + listener: (event: unknown) => void, + options?: AddEventListenerOptions, + ): void; + onmessage?: ((event: MessageEvent) => void) | null; + onerror?: ((event: globalThis.Event) => void) | null; + onclose?: ((event: CloseEvent) => void) | null; + send(data: string): void; + close(): void; +} + +/** Injectable construction options for Realtime transcription. */ +export interface RealtimeTranscriptionOptions { + readonly prompt?: string; + readonly eventId?: () => string; + readonly socket?: (url: string) => RealtimeSocket; +} + +function defaultEventId(): string { + return `client_${crypto.randomUUID()}`; +} + +function defaultSocket(url: string): RealtimeSocket { + return new WebSocket(url); +} + +function socketUrl(): string { + if (typeof location === "undefined") { + return "ws://127.0.0.1/v1/realtime"; + } + const scheme = location.protocol === "https:" ? "wss" : "ws"; + return `${scheme}://${location.host}/v1/realtime`; +} + +function base64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return btoa(binary); +} + +/** + * Owns one OpenAI-compatible Realtime transcription socket. It sends only + * canonical client events and publishes only strictly decoded server events. + */ +export class RealtimeTranscriptionService extends Disposable { + private readonly stateEmitter = this._register(new Emitter()); + private readonly eventEmitter = this._register(new Emitter()); + private readonly errorEmitter = this._register(new Emitter()); + private socket: RealtimeSocket | null = null; + private disposed = false; + private negotiatedHypotheses = false; + private currentState: RealtimeTranscriptionState = "connecting"; + private reconnectDelayMs = RECONNECT_INITIAL_MS; + private reconnectTimer: ReturnType | null = null; + + /** Fires when connection readiness changes. */ + readonly onState: ServiceEvent = this.stateEmitter.event; + /** Fires each server event after strict decoding succeeds. */ + readonly onEvent: ServiceEvent = this.eventEmitter.event; + /** Fires a recoverable transport or server-event decoding failure. */ + readonly onError: ServiceEvent = this.errorEmitter.event; + + constructor(private readonly options: RealtimeTranscriptionOptions = {}) { + super(); + this.connect(); + } + + /** Current connection readiness. */ + get state(): RealtimeTranscriptionState { + return this.currentState; + } + + /** Opens a fresh relay connection after a recoverable outage. */ + connect(): void { + if (this.disposed || this.socket !== null) { + return; + } + this.setState("connecting"); + const socketFactory = this.options.socket ?? defaultSocket; + let socket: RealtimeSocket; + try { + socket = socketFactory(socketUrl()); + } catch { + this.setState("unavailable"); + this.reportError("connection_failed"); + this.scheduleReconnect(); + return; + } + this.socket = socket; + const onMessage = (event: MessageEvent): void => { + if (this.socket === socket) { + this.handleMessage(event.data); + } + }; + const onError = (): void => { + if (this.socket === socket) { + this.socket = null; + this.resetConnectionState(); + this.setState("unavailable"); + this.reportError("connection_failed"); + socket.close(); + this.scheduleReconnect(); + } + }; + const onClose = (): void => { + if (this.socket !== socket) { + return; + } + this.socket = null; + this.resetConnectionState(); + if (!this.disposed) { + this.setState("unavailable"); + this.reportError("connection_closed"); + this.scheduleReconnect(); + } + }; + if (socket.addEventListener !== undefined) { + socket.addEventListener("message", (event) => onMessage(event as MessageEvent)); + socket.addEventListener("error", onError); + socket.addEventListener("close", onClose); + } else { + socket.onmessage = onMessage; + socket.onerror = onError; + socket.onclose = onClose; + } + } + + /** Appends one exact 24 kHz mono PCM16 block and returns its client event ID. */ + append(audio: ArrayBuffer): string | null { + return this.sendClientEvent({ + type: "input_audio_buffer.append", + audio: base64(audio), + }); + } + + /** Commits the current input buffer and returns its client event ID. */ + commit(): string | null { + return this.sendClientEvent({ type: "input_audio_buffer.commit" }); + } + + /** Clears the current input buffer and returns its client event ID. */ + clear(): string | null { + return this.sendClientEvent({ type: "input_audio_buffer.clear" }); + } + + override dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + const socket = this.socket; + this.socket = null; + socket?.close(); + super.dispose(); + } + + private handleMessage(data: unknown): void { + if (typeof data !== "string") { + this.reportError("invalid_server_event", "session"); + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(data); + } catch { + this.reportError("invalid_server_event", "session"); + return; + } + const event = decodeRealtimeEvent(parsed); + if (event === null) { + this.reportError("invalid_server_event", "session"); + return; + } + if ( + event.type === "conversation.item.input_audio_transcription.delta" && + this.negotiatedHypotheses + ) { + return; + } + this.eventEmitter.fire(event); + + switch (event.type) { + case "session.created": + this.send({ + type: "session.update", + session: { + type: "transcription", + audio: { + input: { + format: { type: "audio/pcm", rate: 24_000 }, + noise_reduction: null, + transcription: { + model: "realtime-transcribe", + prompt: this.options.prompt ?? "", + }, + turn_detection: null, + }, + }, + include: [HYPOTHESIS_INCLUDE], + }, + event_id: (this.options.eventId ?? defaultEventId)(), + }); + return; + case "session.updated": + this.negotiatedHypotheses = + event.session.include.length === 1 && + event.session.include[0] === HYPOTHESIS_INCLUDE; + this.reconnectDelayMs = RECONNECT_INITIAL_MS; + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + this.setState("ready"); + return; + case "input_audio_buffer.committed": + case "input_audio_buffer.cleared": + case "conversation.item.created": + case "conversation.item.input_audio_transcription.hypothesis": + case "conversation.item.input_audio_transcription.delta": + case "conversation.item.input_audio_transcription.completed": + case "conversation.item.input_audio_transcription.failed": + return; + case "error": + return; + default: { + const exhaustive: never = event; + return exhaustive; + } + } + } + + private sendClientEvent(event: Record): string | null { + const eventId = (this.options.eventId ?? defaultEventId)(); + return this.send({ ...event, event_id: eventId }) ? eventId : null; + } + + private send(event: Record): boolean { + const socket = this.socket; + if (socket === null || socket.readyState !== 1) { + this.reportError("connection_unavailable"); + return false; + } + try { + socket.send(JSON.stringify(event)); + return true; + } catch { + this.reportError("connection_failed"); + return false; + } + } + + private setState(state: RealtimeTranscriptionState): void { + if (this.currentState === state) { + return; + } + this.currentState = state; + this.stateEmitter.fire(state); + } + + private resetConnectionState(): void { + this.negotiatedHypotheses = false; + } + + private scheduleReconnect(): void { + if (this.disposed || this.socket !== null || this.reconnectTimer !== null) { + return; + } + const delay = this.reconnectDelayMs; + this.reconnectDelayMs = Math.min(delay * 2, RECONNECT_MAX_MS); + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.connect(); + }, delay); + } + + private reportError( + code: string, + scope: RealtimeTranscriptionError["scope"] = "connection", + eventId: string | null = null, + ): void { + this.errorEmitter.fire({ code, scope, eventId, recoverable: true }); + } +} diff --git a/crates/workshop-server/ui/src/services/speech-capture.ts b/crates/workshop-server/ui/src/services/speech-capture.ts new file mode 100644 index 00000000..a68aadb3 --- /dev/null +++ b/crates/workshop-server/ui/src/services/speech-capture.ts @@ -0,0 +1,342 @@ +import { Emitter, type Event } from "../base/event"; +import { Disposable } from "../base/lifecycle"; + +const OUTPUT_SAMPLE_RATE = 24_000; +const FLUSH_TIMEOUT_MS = 1_000; + +/** A successful microphone lifecycle operation. */ +export type SpeechCaptureSuccess = + | { readonly ok: true; readonly kind: "started" } + | { readonly ok: true; readonly kind: "stopped" } + | { readonly ok: true; readonly kind: "cleared" }; + +/** A microphone failure that leaves capture available for another attempt. */ +export type SpeechCaptureFailure = { + readonly ok: false; + readonly kind: + | "permission-denied" + | "device-unavailable" + | "start-failed" + | "stop-failed" + | "clear-failed"; + readonly message: string; + readonly recoverable: true; +}; + +/** The result of a capture lifecycle operation. */ +export type SpeechCaptureOutcome = SpeechCaptureSuccess | SpeechCaptureFailure; + +/** One opened microphone graph owned by a capture service. */ +export interface SpeechCaptureSession { + /** Drops buffered audio without stopping capture. */ + clear(): void; + /** Flushes buffered audio, then stops the graph. */ + stop(): Promise; + /** Immediately releases every graph resource. */ + dispose(): void; +} + +/** Injectable browser-audio boundary used by the DOM-free capture service. */ +export interface SpeechCaptureBackend { + /** Opens a 24 kHz mono PCM16 capture graph. */ + open(emitAudio: (chunk: ArrayBuffer) => void): Promise; +} + +type OpenFailureKind = "permission" | "device" | "start"; + +interface OpenFailure { + readonly kind: OpenFailureKind; + readonly message: string; +} + +function errorText(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "object" && error !== null) { + const message = Reflect.get(error, "message"); + if (typeof message === "string") { + return message; + } + } + return String(error); +} + +function openFailure(kind: OpenFailureKind, error: unknown): OpenFailure { + return { kind, message: errorText(error) }; +} + +function classifyMediaFailure(error: unknown): OpenFailure { + const name = + typeof error === "object" && error !== null && typeof Reflect.get(error, "name") === "string" + ? (Reflect.get(error, "name") as string) + : ""; + if (name === "NotAllowedError" || name === "SecurityError") { + return openFailure("permission", error); + } + return openFailure("device", error); +} + +class BrowserSpeechCaptureSession implements SpeechCaptureSession { + private disposed = false; + private flush: + | { + readonly resolve: () => void; + readonly reject: (error: Error) => void; + readonly timer: ReturnType; + } + | null = null; + + constructor( + private readonly context: AudioContext, + private readonly stream: MediaStream, + private readonly source: MediaStreamAudioSourceNode, + private readonly node: AudioWorkletNode, + emitAudio: (chunk: ArrayBuffer) => void, + ) { + this.node.port.onmessage = (event: MessageEvent) => { + if (event.data instanceof ArrayBuffer) { + emitAudio(event.data); + } else if ( + typeof event.data === "object" && + event.data !== null && + Reflect.get(event.data, "type") === "flushed" + ) { + this.finishFlush(); + } + }; + } + + clear(): void { + if (!this.disposed) { + this.node.port.postMessage({ type: "clear" }); + } + } + + async stop(): Promise { + if (this.disposed) { + return; + } + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.flush = null; + reject(new Error("audio worklet flush timed out")); + }, FLUSH_TIMEOUT_MS); + this.flush = { resolve, reject, timer }; + this.node.port.postMessage({ type: "flush" }); + }); + this.releaseGraph(); + await this.context.close(); + } + + dispose(): void { + if (this.disposed) { + return; + } + this.cancelFlush(); + this.releaseGraph(); + // dispose() is synchronous, so context shutdown completes in the background. + void this.context.close().catch(() => {}); + } + + private finishFlush(): void { + const flush = this.flush; + if (flush === null) { + return; + } + this.flush = null; + clearTimeout(flush.timer); + flush.resolve(); + } + + private cancelFlush(): void { + const flush = this.flush; + if (flush === null) { + return; + } + this.flush = null; + clearTimeout(flush.timer); + flush.reject(new Error("speech capture was disposed while flushing")); + } + + private releaseGraph(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.node.port.onmessage = null; + this.source.disconnect(); + this.node.disconnect(); + for (const track of this.stream.getTracks()) { + track.stop(); + } + } +} + +function browserBackend(): SpeechCaptureBackend { + return { + async open(emitAudio): Promise { + if ( + typeof navigator === "undefined" || + !navigator.mediaDevices?.getUserMedia || + typeof AudioContext === "undefined" || + typeof AudioWorkletNode === "undefined" + ) { + throw openFailure("device", new Error("microphone capture is unavailable")); + } + + let stream: MediaStream; + try { + stream = await navigator.mediaDevices.getUserMedia({ + audio: { + channelCount: 1, + sampleRate: OUTPUT_SAMPLE_RATE, + echoCancellation: true, + noiseSuppression: true, + }, + }); + } catch (error) { + throw classifyMediaFailure(error); + } + + let context: AudioContext | null = null; + let source: MediaStreamAudioSourceNode | null = null; + let node: AudioWorkletNode | null = null; + try { + context = new AudioContext({ sampleRate: OUTPUT_SAMPLE_RATE }); + if (context.sampleRate !== OUTPUT_SAMPLE_RATE) { + throw new Error( + `browser opened audio at ${context.sampleRate} Hz instead of ${OUTPUT_SAMPLE_RATE} Hz`, + ); + } + await context.audioWorklet.addModule("/pcm-worklet.js"); + source = context.createMediaStreamSource(stream); + node = new AudioWorkletNode(context, "pcm16-capture"); + const session = new BrowserSpeechCaptureSession( + context, + stream, + source, + node, + emitAudio, + ); + source.connect(node); + node.connect(context.destination); + await context.resume(); + return session; + } catch (error) { + node?.disconnect(); + source?.disconnect(); + for (const track of stream.getTracks()) { + track.stop(); + } + if (context !== null) { + // Preserve the graph-start error even when best-effort cleanup also fails. + await context.close().catch(() => {}); + } + throw openFailure("start", error); + } + }, + }; +} + +function failure(kind: SpeechCaptureFailure["kind"], error: unknown): SpeechCaptureFailure { + return { ok: false, kind, message: errorText(error), recoverable: true }; +} + +function startFailure(error: unknown): SpeechCaptureFailure { + const kind = + typeof error === "object" && error !== null ? Reflect.get(error, "kind") : undefined; + if (kind === "permission") { + return failure("permission-denied", error); + } + if (kind === "device") { + return failure("device-unavailable", error); + } + return failure("start-failed", error); +} + +/** + * Owns browser microphone capture without touching the DOM. Audio and every + * lifecycle failure are values so a view can recover without rebuilding it. + */ +export class SpeechCaptureService extends Disposable { + private readonly audio = this._register(new Emitter()); + private session: SpeechCaptureSession | null = null; + private phase: "idle" | "starting" | "recording" | "stopping" = "idle"; + private disposed = false; + + /** Fires for each owned little-endian mono PCM16 block at 24 kHz. */ + readonly onAudio: Event = this.audio.event; + + constructor(private readonly backend: SpeechCaptureBackend = browserBackend()) { + super(); + } + + /** Whether a microphone graph is currently recording. */ + get recording(): boolean { + return this.phase === "recording"; + } + + /** Opens capture, returning a recoverable outcome instead of throwing. */ + async start(): Promise { + if (this.disposed || this.phase !== "idle") { + return failure("start-failed", new Error("speech capture is already active")); + } + this.phase = "starting"; + try { + const session = await this.backend.open((chunk) => this.audio.fire(chunk)); + if (this.disposed) { + session.dispose(); + return failure("start-failed", new Error("speech capture was disposed while starting")); + } + this.session = session; + this.phase = "recording"; + return { ok: true, kind: "started" }; + } catch (error) { + this.phase = "idle"; + return startFailure(error); + } + } + + /** Flushes and closes capture, returning any close failure as recoverable. */ + async stop(): Promise { + const session = this.session; + if (session === null) { + return { ok: true, kind: "stopped" }; + } + this.phase = "stopping"; + try { + await session.stop(); + return { ok: true, kind: "stopped" }; + } catch (error) { + return failure("stop-failed", error); + } finally { + session.dispose(); + if (this.session === session) { + this.session = null; + } + this.phase = "idle"; + } + } + + /** Drops carried worklet audio while leaving an active microphone open. */ + clear(): SpeechCaptureOutcome { + try { + this.session?.clear(); + return { ok: true, kind: "cleared" }; + } catch (error) { + return failure("clear-failed", error); + } + } + + override dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.session?.dispose(); + this.session = null; + this.phase = "idle"; + super.dispose(); + } +} diff --git a/crates/workshop-server/ui/src/ui/agent-session-view.ts b/crates/workshop-server/ui/src/ui/agent-session-view.ts index 908924b0..b7d9c589 100644 --- a/crates/workshop-server/ui/src/ui/agent-session-view.ts +++ b/crates/workshop-server/ui/src/ui/agent-session-view.ts @@ -11,9 +11,8 @@ // Dictation mounts on the same input: a push-to-talk mic beside the // send button drives stt.ts, which splices the transcript into the box // at the cursor. The mic stays visible and clickable whatever the state, -// so a click while blocked names the blocker on the status bar (a probe -// still in flight, a failed probe, no GPU, no provisioned speech models, -// or no wait pinned) instead of the control silently disappearing. A take follows +// so a click while blocked names the blocker on the status bar instead of +// the control silently disappearing. A take follows // the wait it dictates into: when the pinned wait dies - spent by a send, // cancelled by the server, or reset by a new session - the live take is // discarded, because a take that cannot be sent is a trap. @@ -27,14 +26,13 @@ import type { TranscriptItem, } from "../services/agent-session"; import type { ModelService } from "../services/model-service"; +import { SpeechCaptureService } from "../services/speech-capture"; import { AgentToolbar } from "./agent-toolbar"; import { renderMarkdown } from "./markdown-render"; import { PromptInput } from "./prompt-input"; import { ToolCallCard } from "./tool-call-card"; import { setupStt, - sttCapability, - type SttCapability, type SttHandle, type SttStatus, } from "./stt"; @@ -175,9 +173,10 @@ function renderItem(item: TranscriptItem, resultIds: ReadonlySet): Paint * input bar. The toolbar (mode chip, model picker, context ring) mounts * only when the composition root threads a ModelService through; a view * built without one mounts none. The input enables only while a wait is - * pinned; submitting answers the wait through the service and clears - * the box on a successful send. The status sink receives dictation's - * local messages and recording LED state. + * pinned; a configured model service gates submission until its current + * selection is non-empty. Submitting answers the wait through the service + * and clears the box on a successful send. The status sink receives + * dictation's local messages, selection blockers, and recording LED state. */ export class AgentSessionView extends Disposable { readonly element: HTMLElement; @@ -191,13 +190,12 @@ export class AgentSessionView extends Disposable { private readonly send: HTMLButtonElement; private readonly stt: SttHandle; private rendered: RenderedRow[] = []; - /** The capability probe's answer; undefined while it is in flight. */ - private capability: SttCapability | null | undefined; constructor( private readonly service: AgentSessionService, - status: SttStatus, - modelService?: ModelService, + private readonly status: SttStatus, + private readonly modelService?: ModelService, + speechCapture?: SpeechCaptureService, ) { super(); this.element = document.createElement("section"); @@ -255,42 +253,30 @@ export class AgentSessionView extends Disposable { this.renderInputState(); }), ); + if (this.modelService !== undefined) { + this._register(this.modelService.onDidChangeCurrent(() => this.renderInputState())); + } // The dictation control over the mic and input. Registered before the // prompt input so disposal discards a live take while the editor - // still stands. The blocker names the first reason a take cannot - // start, capability before the wait. The probe resolves after mount; - // a click that beats it is refused, because a server with no engine - // still accepts /stt and answers an empty final, so an unchecked - // take would record for nothing. + // still stands. Production injects the composition root's capture + // service; isolated views own a fallback for tests and previews. + const capture = speechCapture ?? new SpeechCaptureService(); this.stt = this._register( setupStt({ mic: this.mic, input: promptInput }, status, () => { - if (this.capability === undefined) { - return "Dictation is still checking what this server can do; try again in a moment."; - } - if (this.capability === null) { - return "Dictation is unavailable: the server's capability probe failed."; - } - if (!this.capability.gpu) { - return "Dictation needs a GPU this server doesn't have."; - } - if (!this.capability.engine) { - return "No speech models are provisioned in the active profile."; - } if (this.service.pendingInputToken === null) { return "The agent isn't asking for input; the mic opens when it does."; } return null; - }), + }, capture), ); + if (speechCapture === undefined) { + this._register(capture); + } this.promptInput = this._register(promptInput); this.renderFeed(); this.renderInputState(); - - void sttCapability().then((answer) => { - this.capability = answer; - }); } /** @@ -334,6 +320,10 @@ export class AgentSessionView extends Disposable { const pinned = this.service.pendingInputToken !== null; this.promptInput.setEditable(pinned); this.send.disabled = !pinned; + this.send.setAttribute( + "aria-disabled", + String(pinned && this.modelService !== undefined && this.modelService.current === ""), + ); } /** @@ -349,6 +339,10 @@ export class AgentSessionView extends Disposable { if (text === "" || this.service.pendingInputToken === null) { return; } + if (this.modelService !== undefined && this.modelService.current === "") { + this.status.showLocal("Select a model before sending.", "info"); + return; + } // Read before discarding: the discard restores the box to its // pre-take text, and the send carries what was showing. this.stt.discardIfRecording(); diff --git a/crates/workshop-server/ui/src/ui/prompt-input.ts b/crates/workshop-server/ui/src/ui/prompt-input.ts index 67364e6a..890ad33f 100644 --- a/crates/workshop-server/ui/src/ui/prompt-input.ts +++ b/crates/workshop-server/ui/src/ui/prompt-input.ts @@ -13,7 +13,7 @@ import { Editor, type JSONContent } from "@tiptap/core"; import { Placeholder } from "@tiptap/extension-placeholder"; import { StarterKit } from "@tiptap/starter-kit"; import { Disposable, toDisposable } from "../base/lifecycle"; -import type { SttInputTarget } from "./stt"; +import type { SttInputTarget, SttInsertionContext } from "./stt"; import { MentionChip, MentionSuggestionPluginKey } from "./workshop/mention-chip"; // The fallbacks mirror the token defaults in shared-ui/tokens.css; they @@ -68,7 +68,7 @@ export interface PromptInputOptions { * editor, which empties and unwires the ProseMirror DOM. * * Implements {@link SttInputTarget}: dictation splices the transcript in - * through getSelection/replaceRange and holds the box with setReadOnly. + * through insertionContext/replaceRange and holds the box with setReadOnly. * The target's offsets are ProseMirror positions. */ export class PromptInput extends Disposable implements SttInputTarget { @@ -211,10 +211,20 @@ export class PromptInput extends Disposable implements SttInputTarget { this.editor.commands.setTextSelection(this.editor.state.doc.content.size - 1); } - /** The selection as ProseMirror positions - the SttInputTarget coordinate space. */ - getSelection(): { start: number; end: number } { + /** Captures the ProseMirror selection and its target-owned insertion policy. */ + insertionContext(): SttInsertionContext { const { from, to } = this.editor.state.selection; - return { start: from, end: to }; + const document = this.editor.state.doc; + return { + range: { start: from, end: to }, + original: document.textBetween(from, to, "\n", "\n"), + compositionPrefix: + from === to && + to === document.content.size - 1 && + /\S$/.test(document.textBetween(0, from, "\n", "\n")) + ? " " + : "", + }; } /** Places the cursor or selection at ProseMirror positions. */ diff --git a/crates/workshop-server/ui/src/ui/realtime-stt.ts b/crates/workshop-server/ui/src/ui/realtime-stt.ts new file mode 100644 index 00000000..44db3be2 --- /dev/null +++ b/crates/workshop-server/ui/src/ui/realtime-stt.ts @@ -0,0 +1,263 @@ +import { DisposableStore, toDisposable } from "../base/lifecycle"; +import { RealtimeTranscriptionService } from "../services/realtime-transcription"; +import { + SpeechCaptureService, + type SpeechCaptureFailure, + type SpeechCaptureOutcome, +} from "../services/speech-capture"; +import type { + SttBlocker, + SttElements, + SttHandle, + SttStatus, +} from "./stt"; +import { + createTakeRegistry, + reduceTakeRegistry, + type TakeRegistry, + type TakeRegistryEffect, + type TakeRegistryInput, +} from "./take-registry"; + +function captureFailureLabel(failure: SpeechCaptureFailure): string { + if (failure.kind === "permission-denied") { + return "Microphone permission was denied."; + } + if (failure.kind === "device-unavailable") { + return "No microphone is available."; + } + if (failure.kind === "stop-failed") { + return "Dictation could not finish capturing audio. Try again."; + } + return "Dictation could not start. Try again."; +} + +/** + * Wires push-to-talk UI to production PCM16 capture and the additive Realtime + * relay. The registry exclusively owns take state; this layer interprets its + * typed editor, capture, status, and wire effects. + */ +export function setupStt( + elements: SttElements, + status: SttStatus, + blocked: SttBlocker, + capture: SpeechCaptureService, + providedRealtime?: RealtimeTranscriptionService, +): SttHandle { + const { mic, input } = elements; + const store = new DisposableStore(); + const realtime = providedRealtime ?? store.add(new RealtimeTranscriptionService()); + let registry: TakeRegistry = createTakeRegistry(); + let pendingCaptureStop: Promise | null = null; + let disposed = false; + + function setRecording(recording: boolean): void { + mic.classList.toggle("stt-mic--recording", recording); + mic.setAttribute("aria-pressed", String(recording)); + mic.title = recording ? "Stop recording" : "Push to talk"; + status.setRecording(recording); + } + + function releaseCapture(): Promise { + if (pendingCaptureStop !== null) { + return pendingCaptureStop; + } + const stoppingCapture = capture.stop(); + pendingCaptureStop = stoppingCapture; + void stoppingCapture.finally(() => { + if (pendingCaptureStop === stoppingCapture) { + pendingCaptureStop = null; + } + }); + return stoppingCapture; + } + + function interpretEffect(effect: TakeRegistryEffect): void { + switch (effect.domain) { + case "editor": + switch (effect.command) { + case "replace": + input.replaceRange(effect.from, effect.to, effect.text); + return; + case "read-only": + input.setReadOnly(effect.readOnly); + return; + case "focus": + input.focus(); + return; + default: { + const exhaustive: never = effect; + return exhaustive; + } + } + case "capture": + switch (effect.command) { + case "clear": + capture.clear(); + return; + case "stop": { + const stoppingCapture = releaseCapture(); + void stoppingCapture.then((outcome) => { + if (!disposed) { + dispatch({ + type: "capture.stopped", + takeId: effect.takeId, + ok: outcome.ok, + }); + } + }); + return; + } + default: { + const exhaustive: never = effect; + return exhaustive; + } + } + case "status": + switch (effect.command) { + case "recording": + setRecording(effect.recording); + return; + case "local": + status.showLocal(effect.label, effect.severity); + return; + default: { + const exhaustive: never = effect; + return exhaustive; + } + } + case "wire": + switch (effect.command) { + case "append": + dispatch({ + type: "wire.result", + requestId: effect.requestId, + eventId: realtime.append(effect.chunk), + }); + return; + case "commit": + dispatch({ + type: "wire.result", + requestId: effect.requestId, + eventId: realtime.commit(), + }); + return; + case "clear": + realtime.clear(); + return; + default: { + const exhaustive: never = effect; + return exhaustive; + } + } + default: { + const exhaustive: never = effect; + return exhaustive; + } + } + } + + function dispatch(inputEvent: TakeRegistryInput): void { + const transition = reduceTakeRegistry(registry, inputEvent); + registry = transition.state; + for (const effect of transition.effects) { + interpretEffect(effect); + } + } + + store.add( + realtime.onEvent((event) => { + dispatch({ type: "server.event", event }); + }), + ); + store.add( + realtime.onState((state) => { + if (state === "ready") { + dispatch({ type: "connection.ready" }); + } + }), + ); + store.add( + realtime.onError((error) => { + if (error.scope === "connection") { + dispatch({ type: "connection.lost" }); + } else if (error.scope === "session") { + dispatch({ type: "service.error", eventId: error.eventId }); + } + }), + ); + store.add( + capture.onAudio((chunk) => { + dispatch({ type: "capture.audio", chunk }); + }), + ); + + async function start(): Promise { + const reason = blocked(); + if (reason !== null) { + status.showLocal(reason, "info"); + return; + } + if (pendingCaptureStop !== null) { + await pendingCaptureStop; + if ( + disposed || + registry.activeTakeId !== null || + registry.capture !== "idle" + ) { + return; + } + } + if (realtime.state !== "ready") { + realtime.connect(); + status.showLocal("Dictation is connecting. Try again in a moment.", "info"); + return; + } + const outcome = await capture.start(); + if (!outcome.ok) { + status.showLocal(captureFailureLabel(outcome), "error"); + return; + } + if (disposed) { + void releaseCapture(); + return; + } + dispatch({ type: "user.start", context: input.insertionContext() }); + if ( + registry.activeTakeId === null || + registry.capture !== "recording" + ) { + void releaseCapture(); + } + } + + function stop(): void { + dispatch({ type: "user.stop" }); + } + + function discardIfRecording(): void { + dispatch({ type: "user.discard" }); + } + + const onMicClick = (): void => { + if (registry.activeTakeId !== null) { + stop(); + } else { + void start(); + } + }; + mic.addEventListener("click", onMicClick); + store.add(toDisposable(() => mic.removeEventListener("click", onMicClick))); + + return { + discardIfRecording, + dispose(): void { + if (disposed) { + return; + } + disposed = true; + discardIfRecording(); + store.dispose(); + }, + }; +} diff --git a/crates/workshop-server/ui/src/ui/stt.ts b/crates/workshop-server/ui/src/ui/stt.ts index ad069b37..7806db60 100644 --- a/crates/workshop-server/ui/src/ui/stt.ts +++ b/crates/workshop-server/ui/src/ui/stt.ts @@ -1,30 +1,34 @@ -// Push-to-talk dictation over the /stt WebSocket: binary f32 PCM at -// 16 kHz mono in, "start"/"stop" control words, and JSON text frames out. -// The server answers each "start" with a `stream` frame announcing the -// take's generation and tags every interim/final frame with it; frames -// from an older generation are stale (a stop/restart race) and dropped. -// Dictation behaves like typing at the cursor: each take captures the -// selection at record start, splices committed+tentative into that range, -// and sets readOnly so the user cannot disturb the insertion geometry. -// A `final` frame replaces the inserted region with polished text and -// releases readOnly; consecutive takes compose because the cursor position -// is captured fresh each time. +// Shared UI contracts and text-target adapters for Realtime dictation. import "./stt.css"; -import { DisposableStore, toDisposable, type IDisposable } from "../base/lifecycle"; +import type { IDisposable } from "../base/lifecycle"; +export { setupStt } from "./realtime-stt"; + +/** One immutable snapshot of the target-owned transcript insertion policy. */ +export interface SttInsertionContext { + /** The selected range in the target's coordinate space. */ + readonly range: { + readonly start: number; + readonly end: number; + }; + /** The selected text a cancelled or failed take restores. */ + readonly original: string; + /** The separator owned by this take, if appending requires one. */ + readonly compositionPrefix: "" | " "; +} /** * What dictation needs from its host input: a text target the take can * splice the transcript into. Offsets are the target's own text * coordinates - a textarea's string offsets, the prompt editor's - * ProseMirror positions. A take only ever combines a captured `start` - * with the length of the text it last inserted there, which is valid in - * both spaces. + * ProseMirror positions. A take preserves both captured endpoints for + * its first splice, then combines `start` with the length of the text it + * inserted there, which is valid in both spaces. */ export interface SttInputTarget { - /** The current selection: the take's insertion anchor. */ - getSelection(): { start: number; end: number }; + /** Captures the selected range, rollback text, and target-owned insertion policy. */ + insertionContext(): SttInsertionContext; /** Replaces [from, to] with text, leaving the cursor after the inserted text. */ replaceRange(from: number, to: number, text: string): void; /** Locks the input against typing while a take splices, or releases it. */ @@ -44,10 +48,18 @@ export interface SttElements { */ export function textareaSttTarget(input: HTMLTextAreaElement): SttInputTarget { return { - getSelection: () => ({ - start: input.selectionStart ?? input.value.length, - end: input.selectionEnd ?? input.value.length, - }), + insertionContext: () => { + const start = input.selectionStart ?? input.value.length; + const end = input.selectionEnd ?? input.value.length; + return { + range: { start, end }, + original: input.value.slice(start, end), + compositionPrefix: + start === end && end === input.value.length && /\S$/.test(input.value.slice(0, start)) + ? " " + : "", + }; + }, replaceRange: (from, to, text) => { input.setRangeText(text, from, to, "end"); // Programmatic value sets don't fire the textarea's "input" event, @@ -85,365 +97,3 @@ export type SttBlocker = () => string | null; export interface SttHandle extends IDisposable { discardIfRecording(): void; } - -/** The server's STT capability answer: what dictation can do here. */ -export interface SttCapability { - /** Whether transcription can run on the GPU. */ - gpu: boolean; - /** Whether an STT engine is provisioned and loaded in the active profile. */ - engine: boolean; -} - -/** - * Asks the server what dictation can do here. Any failure - transport, status, - * or a malformed body - answers null, which the caller treats as blocked. - */ -export async function sttCapability(): Promise { - try { - const response = await fetch("/stt/capability"); - if (!response.ok) { - return null; - } - const body: unknown = await response.json(); - if (typeof body !== "object" || body === null) { - return null; - } - const gpu = Reflect.get(body, "gpu"); - const engine = Reflect.get(body, "engine"); - if (typeof gpu !== "boolean" || typeof engine !== "boolean") { - return null; - } - return { gpu, engine }; - } catch { - return null; - } -} - -interface SttSession { - ws: WebSocket; - ctx: AudioContext; - source: MediaStreamAudioSourceNode; - node: AudioWorkletNode; - stream: MediaStream; -} - -interface TakeState { - /** The offset where the take's inserted region starts. */ - from: number; - /** - * The length of the region the take owns: the selection it captured at - * record start, then the last splice it wrote. - */ - length: number; -} - -// One socket's announced stream generation (services/protocol.ts -// StreamFrame), null until the server's announcement arrives. Tracked per -// socket because each take opens its own /stt connection and the server -// counts generations per connection. -interface StreamTracker { - current: number | null; -} - -export function setupStt( - elements: SttElements, - statusBar: SttStatus, - blocked: SttBlocker, -): SttHandle { - const { mic, input } = elements; - let active: SttSession | null = null; - let suppressReplies = false; - let take: TakeState | null = null; - // A stopped take's socket while its final is still in flight. The take - // (and the input's readOnly) stays open until that final lands, the - // socket drops, or a discard closes it; without this handle a discard - // in the stop window would see no session and leave the input locked. - let pendingFinal: WebSocket | null = null; - - function setRecording(next: boolean): void { - mic.classList.toggle("stt-mic--recording", next); - mic.setAttribute("aria-pressed", String(next)); - mic.title = next ? "Stop recording" : "Push to talk"; - } - - function spliceValue(text: string): void { - if (!take) return; - input.replaceRange(take.from, take.from + take.length, text); - take.length = text.length; - } - - // Tears down a session's audio half. The socket half is closed by the - // caller, after any in-flight "stop" reply has had a chance to arrive. - function releaseAudio(session: SttSession): void { - session.node.port.onmessage = null; - session.source.disconnect(); - session.node.disconnect(); - for (const track of session.stream.getTracks()) { - track.stop(); - } - // Best effort: a failed close leaves nothing the page can still act on. - session.ctx.close().catch(() => {}); - } - - function finishTake(finalText: string): void { - if (!take) return; - spliceValue(finalText); - take = null; - input.setReadOnly(false); - } - - function discardTake(): void { - if (!take) return; - spliceValue(""); - take = null; - input.setReadOnly(false); - } - - // Handles one server text message. Returns true when the take is over and - // the socket should close. - function handleSttMessage(data: unknown, stream: StreamTracker): boolean { - if (suppressReplies) return true; - if (typeof data !== "string") { - return true; - } - let msg: { - type?: unknown; - text?: unknown; - committed?: unknown; - tentative?: unknown; - frames?: unknown; - generation?: unknown; - } | null; - try { - msg = JSON.parse(data) as typeof msg; - } catch { - msg = null; - } - if (msg && msg.type === "stream") { - stream.current = typeof msg.generation === "number" ? msg.generation : null; - return false; - } - // A frame tagged with a generation other than the announced one belongs - // to a take the server has already superseded (a stop/restart race): - // drop it and keep listening for the current generation. A frame with - // no generation, or one arriving before any announcement, is treated - // as current, so the client tolerates a server that never announces. - if ( - msg && - (msg.type === "interim" || msg.type === "final") && - typeof msg.generation === "number" && - stream.current !== null && - msg.generation !== stream.current - ) { - return false; - } - if (msg && msg.type === "interim") { - const committed = typeof msg.committed === "string" ? msg.committed : ""; - const tentative = typeof msg.tentative === "string" ? msg.tentative : ""; - const gap = committed !== "" && tentative !== "" && !/\s$/.test(committed) ? " " : ""; - spliceValue(committed + gap + tentative); - return false; - } - if (msg && msg.type === "final") { - const raw = typeof msg.text === "string" ? msg.text : ""; - const text = raw.trimEnd(); - if (text !== "") { - finishTake(text); - input.focus(); - } else { - finishTake(""); - const frames = typeof msg.frames === "number" ? msg.frames : 0; - statusBar.showLocal(`No speech detected (${frames} PCM frames captured).`, "info"); - } - return true; - } - // Anything else is shown verbatim and ends the take. - finishTake(""); - statusBar.showLocal(String(data), "error"); - return true; - } - - function beginTake(): void { - const { start, end } = input.getSelection(); - take = { from: start, length: end - start }; - input.setReadOnly(true); - } - - async function startStt(): Promise { - if (!navigator.mediaDevices?.getUserMedia || !window.AudioContext || !window.WebSocket) { - statusBar.showLocal("Dictation is not available in this browser.", "error"); - return; - } - let stream: MediaStream; - try { - stream = await navigator.mediaDevices.getUserMedia({ - audio: { - channelCount: 1, - sampleRate: 16000, - echoCancellation: true, - noiseSuppression: true, - }, - }); - } catch (error) { - const detail = - error instanceof Error && error.name === "NotAllowedError" - ? "microphone permission denied" - : `microphone unavailable: ${(error as Error).message || error}`; - statusBar.showLocal(detail, "error"); - return; - } - let ws: WebSocket | undefined; - let ctx: AudioContext | undefined; - try { - ws = new WebSocket( - `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/stt`, - ); - ws.binaryType = "arraybuffer"; - await new Promise((resolve, reject) => { - ws!.addEventListener("open", () => resolve(), { once: true }); - ws!.addEventListener("error", () => reject(new Error("the /stt socket failed to open")), { - once: true, - }); - }); - // The context resamples the mic stream to 16 kHz before the worklet - // sees it, so the wire format is 16 kHz mono f32 on any device. - ctx = new AudioContext({ sampleRate: 16000 }); - await ctx.audioWorklet.addModule("/pcm-worklet.js"); - const source = ctx.createMediaStreamSource(stream); - const node = new AudioWorkletNode(ctx, "pcm-capture"); - const session: SttSession = { ws, ctx, source, node, stream }; - suppressReplies = false; - node.port.onmessage = (event) => { - if (active === session && ws!.readyState === WebSocket.OPEN) { - ws!.send(event.data); - } - }; - const generation: StreamTracker = { current: null }; - ws.addEventListener("message", (event) => { - if (handleSttMessage(event.data, generation)) { - if (pendingFinal === ws) { - pendingFinal = null; - } - ws!.close(); - } - }); - ws.addEventListener("close", () => { - if (active === session) { - active = null; - setRecording(false); - statusBar.setRecording(false); - if (take) finishTake(""); - releaseAudio(session); - statusBar.showLocal("The dictation connection dropped.", "error"); - } else if (pendingFinal === ws) { - // Dropped, or the stop deadline closed it, before the final - // landed: the take ends as a live drop does, on the pre-take text. - pendingFinal = null; - finishTake(""); - statusBar.showLocal("The dictation connection dropped before the final transcript.", "error"); - } - }); - source.connect(node); - // The worklet renders silence, so reaching the destination is safe and - // keeps the graph pulling on every engine. - node.connect(ctx.destination); - active = session; - beginTake(); - ws.send("start"); - setRecording(true); - statusBar.setRecording(true); - } catch (error) { - for (const track of stream.getTracks()) { - track.stop(); - } - if (ws) { - ws.close(); - } - if (ctx) { - ctx.close().catch(() => {}); - } - statusBar.showLocal(`Dictation failed: ${(error as Error).message || error}`, "error"); - } - } - - function stopStt(): void { - const session = active; - active = null; - setRecording(false); - statusBar.setRecording(false); - if (!session) { - return; - } - releaseAudio(session); - const { ws } = session; - if (ws.readyState === WebSocket.OPEN) { - ws.send("stop"); - pendingFinal = ws; - // The final whisper pass can take 30+ seconds on CPU; give it time. - // The message listener closes the socket when the final reply arrives. - const deadline = setTimeout(() => { - if (ws.readyState === WebSocket.OPEN) { - ws.close(); - } - }, 120_000); - // The post-stop socket deliberately outlives the session so the - // final reply can land, but the handle still owns it: disposing the - // tab closes the socket and cancels the deadline instead of leaving - // both live (and splicing into a dead textarea) for two minutes. - store.add( - toDisposable(() => { - clearTimeout(deadline); - if (ws.readyState === WebSocket.OPEN) { - ws.close(); - } - }), - ); - } - } - - // Ends a take that is still open: recording, or stopped with its final - // in flight. Either way the socket closes, a late reply is ignored, and - // the input returns to its pre-take text with readOnly lifted. - function discardIfRecording(): void { - const session = active; - const awaited = pendingFinal; - if (!session && !awaited) return; - suppressReplies = true; - active = null; - pendingFinal = null; - if (session) { - releaseAudio(session); - session.ws.close(); - } - // A new take may have started while the previous stop's final was - // still in flight; both sockets go. - if (awaited) { - awaited.close(); - } - discardTake(); - setRecording(false); - statusBar.setRecording(false); - } - - const onMicClick = (): void => { - if (active) { - stopStt(); - return; - } - const reason = blocked(); - if (reason !== null) { - statusBar.showLocal(reason, "info"); - return; - } - void startStt(); - }; - mic.addEventListener("click", onMicClick); - - const store = new DisposableStore(); - // Teardown order matters: the click listener detaches before the live - // session is discarded, so a click cannot start a new take mid-teardown. - store.add(toDisposable(() => mic.removeEventListener("click", onMicClick))); - store.add(toDisposable(() => discardIfRecording())); - - return { discardIfRecording, dispose: (): void => store.dispose() }; -} diff --git a/crates/workshop-server/ui/src/ui/take-registry-events.ts b/crates/workshop-server/ui/src/ui/take-registry-events.ts new file mode 100644 index 00000000..6b8e02f4 --- /dev/null +++ b/crates/workshop-server/ui/src/ui/take-registry-events.ts @@ -0,0 +1,283 @@ +import type { RealtimeEvent } from "../services/realtime-event-decoder"; +import { + activeTake, + bindItem, + composeTranscript, + removeTake, + replaceTake, + reserveWireRequest, + retireItem, + rollbackAll, + rollbackTake, + takeById, + takeByItem, +} from "./take-registry-state"; +import type { Reduction } from "./take-registry-types"; + +const UNAVAILABLE_LABEL = "Dictation is temporarily unavailable. Try again."; +const TRANSCRIPTION_FAILED_LABEL = "Dictation could not be transcribed. Try again."; + +/** Applies one trusted decoded server event to the registry. */ +export function serverEvent(reduction: Reduction, event: RealtimeEvent): void { + switch (event.type) { + case "session.created": + return; + case "session.updated": + reduction.state.connection = "ready"; + return; + case "input_audio_buffer.committed": + acknowledgeCommit(reduction, event.item_id); + return; + case "input_audio_buffer.cleared": + case "conversation.item.created": + return; + case "conversation.item.input_audio_transcription.hypothesis": + applySnapshot(reduction, event.item_id, event.transcript, false); + return; + case "conversation.item.input_audio_transcription.delta": + applySnapshot(reduction, event.item_id, event.delta, true); + return; + case "conversation.item.input_audio_transcription.completed": + completeTake(reduction, event.item_id, event.transcript); + return; + case "conversation.item.input_audio_transcription.failed": { + const take = takeByItem(reduction.state, event.item_id); + if (take !== null) { + rollbackTake(reduction, take.id); + } + reduction.effects.push({ + domain: "status", + command: "local", + label: TRANSCRIPTION_FAILED_LABEL, + severity: "error", + }); + return; + } + case "error": + serviceError(reduction, event.error.event_id ?? null); + return; + default: { + const exhaustive: never = event; + return exhaustive; + } + } +} + +function acknowledgeCommit(reduction: Reduction, itemId: string): void { + const boundTake = takeByItem(reduction.state, itemId); + if (boundTake !== null) { + const ownerIndex = reduction.state.awaitingCommit.findIndex( + (expectation) => expectation.takeId === boundTake.id, + ); + if (ownerIndex >= 0) { + reduction.state.awaitingCommit.splice(ownerIndex, 1); + } + return; + } + const tombstoneIndex = reduction.state.awaitingCommit.findIndex( + (expectation) => + expectation.takeId === null && expectation.itemId === itemId, + ); + if (tombstoneIndex >= 0) { + reduction.state.awaitingCommit.splice(tombstoneIndex, 1); + return; + } + if (reduction.state.retiredItemIds.includes(itemId)) { + return; + } + if (reduction.state.awaitingCommit.length === 0) { + const active = activeTake(reduction.state); + if (active !== null && active.itemId === null) { + bindItem(reduction.state, active.id, itemId); + return; + } + retireItem(reduction.state, itemId); + reduction.effects.push({ + domain: "status", + command: "local", + label: UNAVAILABLE_LABEL, + severity: "error", + }); + return; + } + + const expectation = reduction.state.awaitingCommit.shift(); + if (expectation === undefined) { + retireItem(reduction.state, itemId); + return; + } + if (expectation.takeId === null) { + retireItem(reduction.state, itemId); + return; + } + const take = takeById(reduction.state, expectation.takeId); + if (take === null) { + retireItem(reduction.state, itemId); + return; + } + if (take.itemId === null) { + bindItem(reduction.state, take.id, itemId); + return; + } + if (take.itemId === itemId) { + return; + } + retireItem(reduction.state, itemId); + rollbackTake(reduction, take.id); + reduction.effects.push({ + domain: "status", + command: "local", + label: UNAVAILABLE_LABEL, + severity: "error", + }); +} + +function applySnapshot( + reduction: Reduction, + itemId: string, + incoming: string, + append: boolean, +): void { + let take = takeByItem(reduction.state, itemId); + if (take === null) { + if ( + reduction.state.retiredItemIds.includes(itemId) || + reduction.state.awaitingCommit.some( + (expectation) => + expectation.takeId === null && expectation.itemId === null, + ) + ) { + return; + } + const unbound = reduction.state.takes.filter( + (candidate) => candidate.itemId === null, + ); + if (unbound.length !== 1) { + return; + } + bindItem(reduction.state, unbound[0].id, itemId); + take = takeById(reduction.state, unbound[0].id); + } + if (take === null) { + return; + } + const transcript = append ? take.deltaText + incoming : incoming; + replaceTake(reduction, take.id, composeTranscript(take, transcript), transcript); +} + +function completeTake( + reduction: Reduction, + itemId: string, + transcript: string, +): void { + const take = takeByItem(reduction.state, itemId); + if (take === null) { + return; + } + if ( + reduction.state.activeTakeId === take.id && + reduction.state.capture === "recording" + ) { + reduction.state.capture = "stopping"; + reduction.state.stoppingTakeId = take.id; + reduction.effects.push( + { domain: "capture", command: "stop", takeId: take.id }, + { domain: "status", command: "recording", recording: false }, + ); + reduction.state.activeTakeId = null; + } + const authoritative = transcript.trimEnd(); + const text = composeTranscript(take, authoritative); + replaceTake(reduction, take.id, text, authoritative); + removeTake(reduction, take.id); + if (text === "") { + reduction.effects.push({ + domain: "status", + command: "local", + label: "No speech was detected.", + severity: "info", + }); + } else { + reduction.effects.push( + { domain: "editor", command: "focus" }, + { + domain: "status", + command: "local", + label: "Dictation ready.", + severity: "info", + }, + ); + } +} + +/** Applies a locally classified service failure without trusting remote wording. */ +export function serviceError( + reduction: Reduction, + eventId: string | null, +): void { + const takeId = + eventId === null + ? reduction.state.activeTakeId + : reduction.state.clientEvents.find((binding) => binding.eventId === eventId) + ?.takeId ?? null; + if (takeId !== null && takeById(reduction.state, takeId) !== null) { + failTake(reduction, takeId); + return; + } + reduction.effects.push({ + domain: "status", + command: "local", + label: UNAVAILABLE_LABEL, + severity: "error", + }); +} + +/** Rolls all live ownership back when the Realtime connection is lost. */ +export function connectionLost(reduction: Reduction): void { + reduction.state.connection = "unavailable"; + const activeTakeId = reduction.state.activeTakeId; + if (activeTakeId !== null && reduction.state.capture !== "idle") { + reduction.effects.push( + { domain: "capture", command: "clear" }, + { domain: "capture", command: "stop", takeId: activeTakeId }, + ); + reduction.state.capture = "stopping"; + reduction.state.stoppingTakeId = activeTakeId; + } + rollbackAll(reduction); + reduction.state.awaitingCommit = []; + reduction.state.pendingWire = []; + reduction.effects.push( + { domain: "status", command: "recording", recording: false }, + { + domain: "status", + command: "local", + label: UNAVAILABLE_LABEL, + severity: "error", + }, + ); +} + +/** Rolls one failed take back through typed capture, wire, editor, and status effects. */ +export function failTake(reduction: Reduction, takeId: number): void { + if ( + reduction.state.activeTakeId === takeId && + reduction.state.capture !== "idle" + ) { + reduction.effects.push( + { domain: "capture", command: "clear" }, + { domain: "capture", command: "stop", takeId }, + { domain: "wire", command: "clear" }, + { domain: "status", command: "recording", recording: false }, + ); + reduction.state.capture = "stopping"; + reduction.state.stoppingTakeId = takeId; + } + rollbackTake(reduction, takeId); + reduction.effects.push({ + domain: "status", + command: "local", + label: UNAVAILABLE_LABEL, + severity: "error", + }); +} diff --git a/crates/workshop-server/ui/src/ui/take-registry-state.ts b/crates/workshop-server/ui/src/ui/take-registry-state.ts new file mode 100644 index 00000000..1873de52 --- /dev/null +++ b/crates/workshop-server/ui/src/ui/take-registry-state.ts @@ -0,0 +1,177 @@ +import type { + MutableRegistry, + PendingWireRequest, + Reduction, + RegistryTake, + TakeRegistry, +} from "./take-registry-types"; + +/** Clones every registry collection for one immutable transition. */ +export function cloneRegistry(state: TakeRegistry): MutableRegistry { + return { + takes: state.takes.map((take) => ({ ...take })), + awaitingCommit: state.awaitingCommit.map((expectation) => ({ ...expectation })), + retiredItemIds: [...state.retiredItemIds], + clientEvents: state.clientEvents.map((binding) => ({ ...binding })), + pendingWire: state.pendingWire.map((request) => ({ ...request })), + activeTakeId: state.activeTakeId, + capture: state.capture, + stoppingTakeId: state.stoppingTakeId, + connection: state.connection, + nextTakeId: state.nextTakeId, + nextRequestId: state.nextRequestId, + }; +} + +/** Reserves one typed request correlation identifier. */ +export function reserveWireRequest( + state: MutableRegistry, + command: PendingWireRequest["command"], + takeId: number, +): number { + const id = state.nextRequestId; + state.nextRequestId += 1; + state.pendingWire.push({ id, command, takeId }); + return id; +} + +/** Restores all owned regions in reverse document order. */ +export function rollbackAll(reduction: Reduction): void { + const takeIds = reduction.state.takes.map((take) => take.id).reverse(); + for (const takeId of takeIds) { + rollbackTake(reduction, takeId); + } +} + +/** Restores and retires one owned region. */ +export function rollbackTake(reduction: Reduction, takeId: number): void { + const take = takeById(reduction.state, takeId); + if (take === null) { + return; + } + replaceTake(reduction, take.id, take.original, ""); + removeTake(reduction, take.id); +} + +/** Replaces one region and shifts every later region by the exact coordinate delta. */ +export function replaceTake( + reduction: Reduction, + takeId: number, + text: string, + deltaText: string, +): void { + const index = reduction.state.takes.findIndex((take) => take.id === takeId); + if (index < 0) { + return; + } + const take = reduction.state.takes[index]; + const oldEnd = take.to; + const nextEnd = take.from + text.length; + const delta = nextEnd - oldEnd; + reduction.effects.push({ + domain: "editor", + command: "replace", + from: take.from, + to: oldEnd, + text, + }); + reduction.state.takes[index] = { + ...take, + to: nextEnd, + text, + deltaText, + }; + if (delta === 0) { + return; + } + reduction.state.takes = reduction.state.takes.map((other) => + other.id !== take.id && other.from >= oldEnd + ? { ...other, from: other.from + delta, to: other.to + delta } + : other, + ); +} + +/** Removes one take while retaining any outstanding acknowledgment owner. */ +export function removeTake(reduction: Reduction, takeId: number): void { + const take = takeById(reduction.state, takeId); + if (take === null) { + return; + } + reduction.state.takes = reduction.state.takes.filter( + (candidate) => candidate.id !== takeId, + ); + reduction.state.awaitingCommit = reduction.state.awaitingCommit.map( + (expectation) => + expectation.takeId === takeId + ? { takeId: null, itemId: take.itemId } + : expectation, + ); + if (take.itemId !== null) { + retireItem(reduction.state, take.itemId); + } + if (reduction.state.activeTakeId === takeId) { + reduction.state.activeTakeId = null; + } + reduction.state.clientEvents = reduction.state.clientEvents.filter( + (binding) => binding.takeId !== takeId, + ); + if (reduction.state.takes.length === 0) { + reduction.effects.push({ + domain: "editor", + command: "read-only", + readOnly: false, + }); + } +} + +/** Applies the target-owned separator once to a transcript. */ +export function composeTranscript(take: RegistryTake, transcript: string): string { + return take.compositionPrefix !== "" && + transcript !== "" && + !/^\s/.test(transcript) + ? take.compositionPrefix + transcript + : transcript; +} + +/** Binds one trusted server item identifier to its take. */ +export function bindItem( + state: MutableRegistry, + takeId: number, + itemId: string, +): void { + const index = state.takes.findIndex((take) => take.id === takeId); + if (index < 0 || state.retiredItemIds.includes(itemId)) { + return; + } + state.takes[index] = { ...state.takes[index], itemId }; +} + +/** Records one item identifier as permanently unable to mutate a take. */ +export function retireItem(state: MutableRegistry, itemId: string): void { + if (!state.retiredItemIds.includes(itemId)) { + state.retiredItemIds.push(itemId); + } +} + +/** Returns the active take when its owner still exists. */ +export function activeTake(state: MutableRegistry): RegistryTake | null { + return state.activeTakeId === null + ? null + : takeById(state, state.activeTakeId); +} + +/** Finds one take by local identifier. */ +export function takeById( + state: MutableRegistry, + takeId: number, +): RegistryTake | null { + return state.takes.find((take) => take.id === takeId) ?? null; +} + +/** Finds one take by trusted server item identifier. */ +export function takeByItem( + state: MutableRegistry, + itemId: string, +): RegistryTake | null { + return state.takes.find((take) => take.itemId === itemId) ?? null; +} diff --git a/crates/workshop-server/ui/src/ui/take-registry-types.ts b/crates/workshop-server/ui/src/ui/take-registry-types.ts new file mode 100644 index 00000000..fc9f7afd --- /dev/null +++ b/crates/workshop-server/ui/src/ui/take-registry-types.ts @@ -0,0 +1,159 @@ +import type { RealtimeEvent } from "../services/realtime-event-decoder"; +import type { SttInsertionContext } from "./stt"; + +/** One transcript region owned by a Realtime audio take. */ +export interface RegistryTake { + readonly id: number; + readonly from: number; + readonly to: number; + readonly original: string; + readonly compositionPrefix: "" | " "; + readonly itemId: string | null; + readonly text: string; + readonly deltaText: string; +} + +/** One wire request waiting for its client event identifier. */ +export interface PendingWireRequest { + readonly id: number; + readonly command: "append" | "commit"; + readonly takeId: number; +} + +/** One client event identifier bound to its owning take. */ +export interface ClientEventBinding { + readonly eventId: string; + readonly takeId: number; +} + +/** One FIFO commit owner or a retired owner's acknowledgment tombstone. */ +export interface CommitExpectation { + readonly takeId: number | null; + readonly itemId: string | null; +} + +/** All immutable state needed to assign and replace transcript regions. */ +export interface TakeRegistry { + readonly takes: readonly RegistryTake[]; + readonly awaitingCommit: readonly CommitExpectation[]; + readonly retiredItemIds: readonly string[]; + readonly clientEvents: readonly ClientEventBinding[]; + readonly pendingWire: readonly PendingWireRequest[]; + readonly activeTakeId: number | null; + readonly capture: "idle" | "recording" | "stopping"; + readonly stoppingTakeId: number | null; + readonly connection: "ready" | "unavailable"; + readonly nextTakeId: number; + readonly nextRequestId: number; +} + +/** A user, capture, connection, or decoded server input to the registry. */ +export type TakeRegistryInput = + | { readonly type: "user.start"; readonly context: SttInsertionContext } + | { readonly type: "user.stop" } + | { readonly type: "user.discard" } + | { readonly type: "capture.audio"; readonly chunk: ArrayBuffer } + | { + readonly type: "capture.stopped"; + readonly takeId: number; + readonly ok: boolean; + } + | { + readonly type: "wire.result"; + readonly requestId: number; + readonly eventId: string | null; + } + | { readonly type: "server.event"; readonly event: RealtimeEvent } + | { readonly type: "service.error"; readonly eventId: string | null } + | { readonly type: "connection.lost" } + | { readonly type: "connection.ready" }; + +/** A target edit the registry asks its UI owner to perform. */ +export type TakeRegistryEditorEffect = + | { + readonly domain: "editor"; + readonly command: "replace"; + readonly from: number; + readonly to: number; + readonly text: string; + } + | { + readonly domain: "editor"; + readonly command: "read-only"; + readonly readOnly: boolean; + } + | { readonly domain: "editor"; readonly command: "focus" }; + +/** A capture operation the registry asks its service owner to perform. */ +export type TakeRegistryCaptureEffect = + | { + readonly domain: "capture"; + readonly command: "stop"; + readonly takeId: number; + } + | { readonly domain: "capture"; readonly command: "clear" }; + +/** A local status update emitted without server-authored wording. */ +export type TakeRegistryStatusEffect = + | { + readonly domain: "status"; + readonly command: "recording"; + readonly recording: boolean; + } + | { + readonly domain: "status"; + readonly command: "local"; + readonly label: string; + readonly severity: "info" | "error"; + }; + +/** A wire operation the registry asks the Realtime owner to perform. */ +export type TakeRegistryWireEffect = + | { + readonly domain: "wire"; + readonly command: "append"; + readonly requestId: number; + readonly takeId: number; + readonly chunk: ArrayBuffer; + } + | { + readonly domain: "wire"; + readonly command: "commit"; + readonly requestId: number; + readonly takeId: number; + } + | { readonly domain: "wire"; readonly command: "clear" }; + +/** A typed operation produced by a pure registry transition. */ +export type TakeRegistryEffect = + | TakeRegistryEditorEffect + | TakeRegistryCaptureEffect + | TakeRegistryStatusEffect + | TakeRegistryWireEffect; + +/** The next immutable registry state and operations for its owners. */ +export interface TakeRegistryTransition { + readonly state: TakeRegistry; + readonly effects: readonly TakeRegistryEffect[]; +} + +/** A private writable clone used only during one pure reduction. */ +export interface MutableRegistry { + takes: RegistryTake[]; + awaitingCommit: CommitExpectation[]; + retiredItemIds: string[]; + clientEvents: ClientEventBinding[]; + pendingWire: PendingWireRequest[]; + activeTakeId: number | null; + capture: TakeRegistry["capture"]; + stoppingTakeId: number | null; + connection: TakeRegistry["connection"]; + nextTakeId: number; + nextRequestId: number; +} + +/** A private transition accumulator used only during one reduction. */ +export interface Reduction { + readonly state: MutableRegistry; + readonly effects: TakeRegistryEffect[]; +} diff --git a/crates/workshop-server/ui/src/ui/take-registry.ts b/crates/workshop-server/ui/src/ui/take-registry.ts new file mode 100644 index 00000000..91806aa2 --- /dev/null +++ b/crates/workshop-server/ui/src/ui/take-registry.ts @@ -0,0 +1,272 @@ +import type { SttInsertionContext } from "./stt"; +import { + connectionLost, + failTake, + serverEvent, + serviceError, +} from "./take-registry-events"; +import { + activeTake, + cloneRegistry, + reserveWireRequest, + rollbackAll, + rollbackTake, + takeById, +} from "./take-registry-state"; +import type { + Reduction, + RegistryTake, + TakeRegistry, + TakeRegistryInput, + TakeRegistryTransition, +} from "./take-registry-types"; + +export type { + RegistryTake, + TakeRegistry, + TakeRegistryCaptureEffect, + TakeRegistryEditorEffect, + TakeRegistryEffect, + TakeRegistryInput, + TakeRegistryStatusEffect, + TakeRegistryTransition, + TakeRegistryWireEffect, +} from "./take-registry-types"; + +/** Creates an empty registry ready for its first take. */ +export function createTakeRegistry(): TakeRegistry { + return { + takes: [], + awaitingCommit: [], + retiredItemIds: [], + clientEvents: [], + pendingWire: [], + activeTakeId: null, + capture: "idle", + stoppingTakeId: null, + connection: "ready", + nextTakeId: 1, + nextRequestId: 1, + }; +} + +/** Applies one input without performing editor, capture, status, or wire work. */ +export function reduceTakeRegistry( + current: TakeRegistry, + input: TakeRegistryInput, +): TakeRegistryTransition { + const reduction: Reduction = { + state: cloneRegistry(current), + effects: [], + }; + switch (input.type) { + case "user.start": + startTake(reduction, input.context); + break; + case "user.stop": + stopTake(reduction); + break; + case "user.discard": + discardTakes(reduction); + break; + case "capture.audio": + appendAudio(reduction, input.chunk); + break; + case "capture.stopped": + captureStopped(reduction, input.takeId, input.ok); + break; + case "wire.result": + wireResult(reduction, input.requestId, input.eventId); + break; + case "server.event": + serverEvent(reduction, input.event); + break; + case "service.error": + serviceError(reduction, input.eventId); + break; + case "connection.lost": + connectionLost(reduction); + break; + case "connection.ready": + reduction.state.connection = "ready"; + break; + default: { + const exhaustive: never = input; + return exhaustive; + } + } + return reduction; +} + +function startTake(reduction: Reduction, context: SttInsertionContext): void { + if ( + reduction.state.activeTakeId !== null || + reduction.state.capture !== "idle" || + reduction.state.connection !== "ready" + ) { + return; + } + const id = reduction.state.nextTakeId; + reduction.state.nextTakeId += 1; + const take: RegistryTake = { + id, + from: context.range.start, + to: context.range.end, + original: context.original, + compositionPrefix: context.compositionPrefix, + itemId: null, + text: context.original, + deltaText: "", + }; + const wasEmpty = reduction.state.takes.length === 0; + reduction.state.takes.push(take); + reduction.state.takes.sort((left, right) => left.from - right.from || left.id - right.id); + reduction.state.activeTakeId = id; + reduction.state.capture = "recording"; + if (wasEmpty) { + reduction.effects.push({ + domain: "editor", + command: "read-only", + readOnly: true, + }); + } + reduction.effects.push( + { domain: "status", command: "recording", recording: true }, + { + domain: "status", + command: "local", + label: "Listening...", + severity: "info", + }, + ); +} + +function stopTake(reduction: Reduction): void { + const take = activeTake(reduction.state); + if (take === null || reduction.state.capture !== "recording") { + return; + } + reduction.state.capture = "stopping"; + reduction.state.stoppingTakeId = take.id; + reduction.effects.push( + { domain: "capture", command: "stop", takeId: take.id }, + { domain: "status", command: "recording", recording: false }, + { + domain: "status", + command: "local", + label: "Transcribing...", + severity: "info", + }, + ); +} + +function appendAudio(reduction: Reduction, chunk: ArrayBuffer): void { + const take = activeTake(reduction.state); + if (take === null || reduction.state.capture === "idle") { + return; + } + const requestId = reserveWireRequest(reduction.state, "append", take.id); + reduction.effects.push({ + domain: "wire", + command: "append", + requestId, + takeId: take.id, + chunk, + }); +} + +function captureStopped(reduction: Reduction, takeId: number, ok: boolean): void { + if ( + reduction.state.capture !== "stopping" || + reduction.state.stoppingTakeId !== takeId + ) { + return; + } + reduction.state.capture = "idle"; + reduction.state.stoppingTakeId = null; + if (reduction.state.activeTakeId === takeId) { + reduction.state.activeTakeId = null; + } + const take = takeById(reduction.state, takeId); + if (take === null) { + return; + } + if (!ok) { + reduction.effects.push({ domain: "wire", command: "clear" }); + rollbackTake(reduction, takeId); + reduction.effects.push({ + domain: "status", + command: "local", + label: "Dictation could not finish capturing audio. Try again.", + severity: "error", + }); + return; + } + const requestId = reserveWireRequest(reduction.state, "commit", takeId); + reduction.effects.push({ + domain: "wire", + command: "commit", + requestId, + takeId, + }); +} + +function discardTakes(reduction: Reduction): void { + if (reduction.state.takes.length === 0) { + return; + } + const activeTakeId = reduction.state.activeTakeId; + if (activeTakeId !== null && reduction.state.capture !== "idle") { + reduction.effects.push( + { domain: "capture", command: "clear" }, + { + domain: "capture", + command: "stop", + takeId: activeTakeId, + }, + { domain: "wire", command: "clear" }, + ); + reduction.state.capture = "stopping"; + reduction.state.stoppingTakeId = activeTakeId; + } + rollbackAll(reduction); + reduction.effects.push({ + domain: "status", + command: "recording", + recording: false, + }); +} + +function wireResult( + reduction: Reduction, + requestId: number, + eventId: string | null, +): void { + const index = reduction.state.pendingWire.findIndex( + (request) => request.id === requestId, + ); + if (index < 0) { + return; + } + const [request] = reduction.state.pendingWire.splice(index, 1); + const take = takeById(reduction.state, request.takeId); + if (eventId === null) { + if (take !== null) { + failTake(reduction, take.id); + } + return; + } + if (take === null) { + if (request.command === "commit") { + reduction.state.awaitingCommit.push({ takeId: null, itemId: null }); + } + return; + } + reduction.state.clientEvents.push({ eventId, takeId: take.id }); + if (request.command === "commit") { + reduction.state.awaitingCommit.push({ + takeId: take.id, + itemId: take.itemId, + }); + } +} diff --git a/crates/workshop-server/ui/src/ui/workshop/agent-panel.ts b/crates/workshop-server/ui/src/ui/workshop/agent-panel.ts index 6c90ab54..c4fdc863 100644 --- a/crates/workshop-server/ui/src/ui/workshop/agent-panel.ts +++ b/crates/workshop-server/ui/src/ui/workshop/agent-panel.ts @@ -10,6 +10,7 @@ import { Disposable } from "../../base/lifecycle"; import { AgentSessionService } from "../../services/agent-session"; import { AgentSocket } from "../../services/agent-socket"; import type { ModelService } from "../../services/model-service"; +import type { SpeechCaptureService } from "../../services/speech-capture"; import { AgentSessionView } from "../agent-session-view"; import type { SttStatus } from "../stt"; @@ -27,6 +28,7 @@ export class AgentPanel extends Disposable implements IContentRenderer { constructor( private readonly status: SttStatus = SILENT_STATUS, private readonly modelService?: ModelService, + private readonly speechCapture?: SpeechCaptureService, ) { super(); this.element.className = "agent-panel"; @@ -35,7 +37,9 @@ export class AgentPanel extends Disposable implements IContentRenderer { init(): void { const socket = this._register(new AgentSocket()); const service = this._register(new AgentSessionService(socket)); - const view = this._register(new AgentSessionView(service, this.status, this.modelService)); + const view = this._register( + new AgentSessionView(service, this.status, this.modelService, this.speechCapture), + ); this.element.appendChild(view.element); this._register( service.onDidChangeAgents((agents) => { diff --git a/crates/workshop-server/ui/src/ui/workshop/panel-types.ts b/crates/workshop-server/ui/src/ui/workshop/panel-types.ts index f00c61bf..ecacf37f 100644 --- a/crates/workshop-server/ui/src/ui/workshop/panel-types.ts +++ b/crates/workshop-server/ui/src/ui/workshop/panel-types.ts @@ -8,6 +8,7 @@ import type { CreateComponentOptions, IContentRenderer, ITabRenderer, TabPartIni import { Disposable } from "../../base/lifecycle"; import type { ModelService } from "../../services/model-service"; +import type { SpeechCaptureService } from "../../services/speech-capture"; import type { SttStatus } from "../stt"; import { AgentPanel } from "./agent-panel"; import { DropdownMenu } from "shared-ui/dropdown"; @@ -26,6 +27,7 @@ import type { ZoneName } from "./zones"; export interface PanelServices { readonly statusBar: TreeStatusSink & SttStatus; readonly modelService: ModelService; + readonly speechCapture: SpeechCaptureService; } /** One panel kind's static registration. */ @@ -75,7 +77,7 @@ export const PANEL_TYPES = { title: "Agent Session", tabComponent: AGENT_TAB, factory: (services?: PanelServices): IContentRenderer => - new AgentPanel(services?.statusBar, services?.modelService), + new AgentPanel(services?.statusBar, services?.modelService, services?.speechCapture), }, } as const satisfies Record; diff --git a/crates/workshop-server/ui/test/agent-session-view.mjs b/crates/workshop-server/ui/test/agent-session-view.mjs index 11736b00..33480a15 100644 --- a/crates/workshop-server/ui/test/agent-session-view.mjs +++ b/crates/workshop-server/ui/test/agent-session-view.mjs @@ -386,6 +386,76 @@ await assertNoLeaks(lifecycle, () => { dispose(); } + // --- A model selection gates every submission path ------------------------ + + { + const status = { + local: [], + showLocal(label, severity) { + this.local.push({ label, severity }); + }, + setRecording() {}, + }; + const modelService = new ModelService(() => true); + const wire = makeWire(); + const service = new AgentSessionService(wire); + const view = new AgentSessionView(service, status, modelService); + window.document.body.appendChild(view.element); + const input = view.promptInput; + const editorEl = view.element.querySelector(".prompt-input__editor"); + const send = view.element.querySelector(".agent-session__send"); + wire.fire.inputRequired("model-gated"); + input.setText("keep this draft"); + check( + "the send control exposes the absent-selection gate", + send.getAttribute("aria-disabled") === "true", + ); + + send.click(); + check( + "click submission without a model is rejected and keeps the draft", + wire.responses.length === 0 && input.getText() === "keep this draft", + ); + check( + "click submission without a model shows the exact local selection status", + isDeepStrictEqual(status.local.at(-1), { + label: "Select a model before sending.", + severity: "info", + }), + ); + + editorEl.dispatchEvent( + new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }), + ); + check( + "keyboard submission without a model is rejected and keeps the draft", + wire.responses.length === 0 && input.getText() === "keep this draft", + ); + check( + "keyboard submission without a model shows the exact local selection status", + status.local.length === 2 && + status.local[1]?.label === "Select a model before sending." && + status.local[1]?.severity === "info", + ); + + modelService.applySelected("alpha"); + check( + "selection arrival immediately lifts the send control gate", + send.getAttribute("aria-disabled") === "false", + ); + send.click(); + check( + "a later model selection makes the pending draft immediately submittable", + isDeepStrictEqual(wire.responses, [["model-gated", "keep this draft"]]) && + input.getText() === "", + ); + + view.dispose(); + service.dispose(); + modelService.dispose(); + view.element.remove(); + } + // --- The placeholder matches Cursor's agent input --------------------------- { diff --git a/crates/workshop-server/ui/test/agent-stt-boot.mjs b/crates/workshop-server/ui/test/agent-stt-boot.mjs index 8dfeb235..1f810219 100644 --- a/crates/workshop-server/ui/test/agent-stt-boot.mjs +++ b/crates/workshop-server/ui/test/agent-stt-boot.mjs @@ -1,7 +1,7 @@ // Dictation on the booted workbench: the mic mounts on the agent session's -// input, the capability probe reaches /stt/capability, a click with no -// wait pinned names the blocker on the real status bar, a live take lights -// the real recording LED, and a dropped /stt socket dims it. The +// input, negotiates the Realtime hypothesis extension, names a missing +// wait on the real status bar, lights the real recording LED for a live +// take, and dims it when the Realtime socket drops. The // behaviors themselves are pinned by test/agent-stt.mjs against the // view; this proves the composition root wires the view to the bar. // Run: node test/agent-stt-boot.mjs (after `npm run build`). @@ -21,19 +21,22 @@ await bootWorkbench("dictation is wired into the booted agent session", async (c if (recEl.classList.contains("status-bar__led--recording")) { failures.push("the recording LED must start dark"); } - // The probe resolves a tick after mount. + // Realtime negotiation resolves a tick after mount. await sleep(20); - // Clicks the mic and waits for a fresh /stt socket with a message - // listener; null when no take began. + // Clicks the mic and waits for the shared production capture service to + // report recording on the already-negotiated Realtime socket. async function startTake() { - const before = sttSockets().length; mic.click(); const deadline = Date.now() + 2000; while (Date.now() < deadline) { - const opened = sttSockets(); - if (opened.length > before && typeof opened.at(-1).onmessage === "function") { - return opened.at(-1); + const socket = sttSockets().at(-1); + if ( + socket && + typeof socket.onmessage === "function" && + recEl.classList.contains("status-bar__led--recording") + ) { + return socket; } await sleep(10); } @@ -43,7 +46,7 @@ await bootWorkbench("dictation is wired into the booted agent session", async (c // No wait pinned: the click is refused and the bar says why. const gated = await startTake(); if (gated) { - failures.push("a mic click with no wait pinned opened a /stt socket"); + failures.push("a mic click with no wait pinned opened a Realtime socket"); } if (!statusText.textContent.includes("isn't asking for input")) { failures.push(`a gated click named no blocker on the status bar (got "${statusText.textContent}")`); @@ -53,16 +56,42 @@ await bootWorkbench("dictation is wired into the booted agent session", async (c emitAgent({ type: "input_required", token: "tok1" }); const sttSocket = await startTake(); if (!sttSocket) { - failures.push("the mic click did not open a /stt socket once a wait was pinned"); + failures.push("the mic click did not start capture once a wait was pinned"); return; } - if (!sttSocket.sent.includes("start")) { - failures.push("the take did not send start on its /stt socket"); + if ( + !sttSocket.sent + .map((event) => JSON.parse(event)) + .some((event) => event.type === "session.update") + ) { + failures.push("the Realtime socket did not negotiate the hypothesis extension"); } if (!recEl.classList.contains("status-bar__led--recording")) { failures.push("starting dictation did not light the recording LED"); } - sttSocket.onmessage({ data: JSON.stringify({ type: "interim", committed: "hello", tentative: "" }) }); + sttSocket.onmessage({ + data: JSON.stringify({ + type: "input_audio_buffer.committed", + event_id: "boot_committed", + item_id: "boot_item", + previous_item_id: null, + }), + }); + sttSocket.onmessage({ + data: JSON.stringify({ + type: "conversation.item.input_audio_transcription.hypothesis", + event_id: "boot_hypothesis", + item_id: "boot_item", + content_index: 0, + revision: 1, + transcript: "hello", + finalized: "hel", + agreed: "l", + tentative: "o", + audio_start_ms: 0, + audio_end_ms: 100, + }), + }); if (input.textContent !== "hello" || input.getAttribute("contenteditable") !== "false") { failures.push(`the interim did not land in the read-only agent input (got "${input.textContent}")`); } @@ -70,10 +99,10 @@ await bootWorkbench("dictation is wired into the booted agent session", async (c // The scripted socket never fires onclose on its own; a drop dims the LED. sttSocket.onclose?.(); if (recEl.classList.contains("status-bar__led--recording")) { - failures.push("a dropped /stt socket did not dim the recording LED"); + failures.push("a dropped Realtime socket did not dim the recording LED"); } if (input.getAttribute("contenteditable") !== "true") { - failures.push("a dropped /stt socket did not lift the input's read-only lock"); + failures.push("a dropped Realtime socket did not lift the input's read-only lock"); } // Closing the Agent tab from its tab chip disposes the panel, the view, diff --git a/crates/workshop-server/ui/test/agent-stt.mjs b/crates/workshop-server/ui/test/agent-stt.mjs index de124808..ffeec07c 100644 --- a/crates/workshop-server/ui/test/agent-stt.mjs +++ b/crates/workshop-server/ui/test/agent-stt.mjs @@ -1,14 +1,10 @@ // Dictation on the agent session input (src/ui/agent-session-view.ts // mounting src/ui/stt.ts), driven through the real AgentSessionService -// over a scripted wire, a scripted /stt socket, stubbed audio, and a -// recording status sink in jsdom. Pins the composer behaviors the mic -// carried before it moved here: the take is gated by the pinned wait and -// by the capability probe (a blocked click names its reason and opens no -// socket); the recording LED follows the recording; interims splice -// committed+tentative at the cursor and the final replaces them in place; -// the input is readOnly for the take's duration; a send discards the live -// take; a dying wait discards it too. Run: node test/agent-stt.mjs -import { writeFile } from "node:fs/promises"; +// over a scripted wire, canonical Realtime events, production capture, +// and a recording status sink in jsdom. It pins local gating and status, +// replacement snapshots, authoritative completion, overlapping items, +// clear, second take, recoverable failure, and disposal. +import { readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -18,6 +14,63 @@ import { JSDOM } from "jsdom"; import { assertNoLeaks } from "./helpers/leak-check.mjs"; const testDir = path.dirname(fileURLToPath(import.meta.url)); +const fixtureDir = path.join( + testDir, + "..", + "..", + "..", + "gateway-stt", + "tests", + "fixtures", + "realtime", +); +const canonicalSequences = JSON.parse( + await readFile(path.join(fixtureDir, "valid-sequences.json"), "utf8"), +); + +function canonicalMessage(sequence, direction, type, occurrence = 0) { + return structuredClone( + canonicalSequences[sequence].events.filter( + (entry) => entry.direction === direction && entry.message.type === type, + )[occurrence].message, + ); +} + +function producerHypothesis(itemId, transcript, revision = 1) { + return { + type: "conversation.item.input_audio_transcription.hypothesis", + event_id: `${itemId}_hypothesis_${revision}`, + item_id: itemId, + content_index: 0, + revision, + transcript, + finalized: transcript, + agreed: "", + tentative: "", + audio_start_ms: 0, + audio_end_ms: 100, + }; +} + +function producerCommitted(itemId) { + return { + type: "input_audio_buffer.committed", + event_id: `${itemId}_committed`, + item_id: itemId, + previous_item_id: null, + }; +} + +function producerCompletion(itemId, transcript) { + return { + type: "conversation.item.input_audio_transcription.completed", + event_id: `${itemId}_completed`, + item_id: itemId, + content_index: 0, + transcript, + usage: { type: "duration", seconds: 0.1 }, + }; +} const bundle = await esbuild.build({ stdin: { @@ -68,11 +121,42 @@ window.Range.prototype.getBoundingClientRect = () => new window.DOMRect(); // Audio stubs: jsdom has no audio stack, so the getUserMedia/AudioContext // path is scripted to succeed. const fakeAudioStream = { getTracks: () => [{ stop() {} }] }; +let delayedMediaStart = null; globalThis.navigator.mediaDevices = { - getUserMedia: () => Promise.resolve(fakeAudioStream), + getUserMedia: () => { + if (delayedMediaStart === null) { + return Promise.resolve(fakeAudioStream); + } + delayedMediaStart.markRequested(); + return delayedMediaStart.stream; + }, }; +function delayNextMediaStart() { + let resolveStream; + let markRequested; + const requested = new Promise((resolve) => { + markRequested = resolve; + }); + const stream = new Promise((resolve) => { + resolveStream = resolve; + }); + const delayed = { + markRequested, + requested, + stream, + release() { + if (delayedMediaStart === delayed) { + delayedMediaStart = null; + } + resolveStream(fakeAudioStream); + }, + }; + delayedMediaStart = delayed; + return delayed; +} class FakeAudioContext { constructor() { + this.sampleRate = 24_000; this.destination = {}; this.audioWorklet = { addModule: () => Promise.resolve() }; } @@ -82,10 +166,28 @@ class FakeAudioContext { close() { return Promise.resolve(); } + resume() { + return Promise.resolve(); + } } +let nextFlushAudio = null; class FakeAudioWorkletNode { constructor() { - this.port = { onmessage: null }; + this.port = { + onmessage: null, + postMessage: (message) => { + if (message?.type === "flush") { + const audio = nextFlushAudio; + nextFlushAudio = null; + queueMicrotask(() => { + if (audio !== null) { + this.port.onmessage?.({ data: audio }); + } + this.port.onmessage?.({ data: { type: "flushed" } }); + }); + } + }, + }; } connect() {} disconnect() {} @@ -94,9 +196,10 @@ window.AudioContext = FakeAudioContext; globalThis.AudioContext = FakeAudioContext; globalThis.AudioWorkletNode = FakeAudioWorkletNode; -// A scripted /stt socket: opens asynchronously like a real one, records +// A scripted Realtime socket: opens asynchronously like a real one, records // what the client sends, and lets the test push server frames. const sockets = []; +let nextItem = 0; class FakeWebSocket { static CONNECTING = 0; static OPEN = 1; @@ -127,7 +230,7 @@ class FakeWebSocket { for (const entry of entries) entry.listener(event); } send(data) { - this.sent.push(data); + this.sent.push(JSON.parse(data)); } close() { if (this.closed) return; @@ -137,39 +240,55 @@ class FakeWebSocket { } // Test-side control, not part of the WebSocket surface. message(frame) { + if (frame.type === "interim") { + if (!this.itemId) { + this.itemId = `item_${++nextItem}`; + } + const finalized = frame.committed ?? ""; + const tentative = `${finalized && frame.tentative && !/\s$/.test(finalized) ? " " : ""}${frame.tentative ?? ""}`; + frame = { + type: "conversation.item.input_audio_transcription.hypothesis", + event_id: `hypothesis_${nextItem}`, + item_id: this.itemId, + content_index: 0, + revision: 1, + transcript: `${finalized}${tentative}`, + finalized, + agreed: "", + tentative, + audio_start_ms: 0, + audio_end_ms: 100, + }; + } else if (frame.type === "final") { + if (!this.itemId) { + this.itemId = `item_${++nextItem}`; + } + this.dispatch("message", { + data: JSON.stringify({ + type: "input_audio_buffer.committed", + event_id: `committed_${nextItem}`, + item_id: this.itemId, + previous_item_id: null, + }), + }); + frame = { + type: "conversation.item.input_audio_transcription.completed", + event_id: `completed_${nextItem}`, + item_id: this.itemId, + content_index: 0, + transcript: frame.text, + usage: { type: "duration", seconds: 0.1 }, + }; + } this.dispatch("message", { data: JSON.stringify(frame) }); + if (frame.type === "conversation.item.input_audio_transcription.completed") { + this.itemId = null; + } } } window.WebSocket = FakeWebSocket; globalThis.WebSocket = FakeWebSocket; -// The capability probe's scripted answer: a body to serve, null to fail -// the fetch, or "pending" to hold the response until the test releases it -// through `answerPendingProbe`. Each harness sets it before the view mounts. -let capabilityAnswer = { gpu: true, engine: true }; -let answerPendingProbe = null; -const probes = []; -const capabilityResponse = (body) => - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); -globalThis.fetch = (url) => { - probes.push(url); - if (url !== "/stt/capability") { - return Promise.reject(new Error(`unexpected fetch in the agent-stt test: ${url}`)); - } - if (capabilityAnswer === null) { - return Promise.reject(new Error("connection refused")); - } - if (capabilityAnswer === "pending") { - return new Promise((resolve) => { - answerPendingProbe = (body) => resolve(capabilityResponse(body)); - }); - } - return Promise.resolve(capabilityResponse(capabilityAnswer)); -}; - const bundlePath = path.join(os.tmpdir(), "promptforge-agent-stt-test.mjs"); await writeFile(bundlePath, bundle.outputFiles[0].text); const { lifecycle, Emitter, AgentSessionService, AgentSessionView } = await import( @@ -183,8 +302,7 @@ function check(name, condition) { const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); -// startStt crosses several await points (getUserMedia, socket open, -// worklet load) before sending "start"; poll until the take is live. +// Capture crosses several await points before the take is live. async function waitFor(condition) { for (let attempt = 0; attempt < 50; attempt++) { if (condition()) return true; @@ -229,13 +347,8 @@ function makeWire() { }; } -// Mounts a view over a fresh service with the probe answering -// `capability`, waits for the probe to settle, and returns the handles. -// `status` records what dictation paints: local messages and the recording -// state. -async function harness(capability = { gpu: true, engine: true }) { - capabilityAnswer = capability; - const probesBefore = probes.length; +// Mounts a view over a fresh service and negotiated Realtime socket. +async function harness() { const status = { local: [], recording: false, @@ -250,9 +363,20 @@ async function harness(capability = { gpu: true, engine: true }) { const service = new AgentSessionService(wire); const view = new AgentSessionView(service, status); window.document.body.appendChild(view.element); - await waitFor(() => probes.length > probesBefore); - // The probe's then-callback lands a tick after the response resolves. - await sleep(10); + await waitFor(() => + sockets.some( + (socket) => + socket.url.endsWith("/v1/realtime") && socket.readyState === FakeWebSocket.OPEN, + ), + ); + const realtime = sockets.filter((socket) => socket.url.endsWith("/v1/realtime")).at(-1); + realtime.message( + canonicalMessage("first_event_readiness", "server", "session.created"), + ); + await waitFor(() => realtime.sent.some((event) => event.type === "session.update")); + realtime.message( + canonicalMessage("hypothesis_negotiation", "server", "session.updated"), + ); const mic = view.element.querySelector(".agent-session__mic"); // The ProseMirror prompt box: content and selection are driven through // the component (the DOM alone sets neither). The pending-wait gate @@ -263,15 +387,12 @@ async function harness(capability = { gpu: true, engine: true }) { const editable = () => editorEl.getAttribute("contenteditable") === "true"; const recording = () => input.element.classList.contains("stt-input--recording"); const send = view.element.querySelector(".agent-session__send"); - // Clicks the mic and waits for the take's /stt socket to open and + // Clicks the mic and waits for the take's Realtime socket to open and // send "start"; null when no take began within the wait. async function startTake() { - const before = sockets.length; mic.click(); - const started = await waitFor( - () => sockets.length > before && sockets.at(-1).sent.includes("start"), - ); - return started ? sockets.at(-1) : null; + const started = await waitFor(() => status.recording); + return started ? realtime : null; } const dispose = () => { view.dispose(); @@ -282,6 +403,211 @@ async function harness(capability = { gpu: true, engine: true }) { } await assertNoLeaks(lifecycle, async () => { + // The shared canonical sequence drives fake media through UI replacement, + // completion, second-take clear, local status, and capture cleanup. + + { + const { wire, status, mic, input, editable, startTake, dispose } = await harness(); + wire.fire.inputRequired("fixture"); + const socket = await startTake(); + if (socket === null) { + failures.push("canonical fixture: the first take did not start"); + dispose(); + return; + } + const canonicalAppend = canonicalMessage( + "immediate_commit_and_provisional_promotion", + "client", + "input_audio_buffer.append", + ); + nextFlushAudio = Uint8Array.from( + Buffer.from(canonicalAppend.audio, "base64"), + ).buffer; + mic.click(); + await waitFor(() => + socket.sent.some((event) => event.type === "input_audio_buffer.commit"), + ); + const append = socket.sent.find( + (event) => event.type === "input_audio_buffer.append", + ); + check( + "canonical fixture emits the shared valid-sized audio append", + append?.audio === canonicalAppend.audio, + ); + const firstHypothesis = canonicalMessage( + "hypothesis_negotiation", + "server", + "conversation.item.input_audio_transcription.hypothesis", + ); + socket.message(firstHypothesis); + check( + "the first precommit hypothesis binds and replaces the active take", + input.getText() === "Hello", + ); + socket.message( + canonicalMessage( + "hypothesis_negotiation", + "server", + "conversation.item.input_audio_transcription.hypothesis", + 1, + ), + ); + check( + "every precommit revision replaces rather than appends", + input.getText() === "Hello!", + ); + const committed = canonicalMessage( + "immediate_commit_and_provisional_promotion", + "server", + "input_audio_buffer.committed", + ); + committed.item_id = firstHypothesis.item_id; + socket.message(committed); + const beforeUnknown = input.getText(); + check( + "the matching acknowledgment confirms without changing provisional text", + input.getText() === "Hello!", + ); + socket.message( + { + ...firstHypothesis, + event_id: "unknown_hypothesis_after_binding", + item_id: "unknown_item", + transcript: "MUST NOT LAND", + }, + ); + check( + "an unknown hypothesis cannot replace a bound take", + input.getText() === beforeUnknown, + ); + socket.message( + canonicalMessage( + "hypothesis_negotiation", + "server", + "conversation.item.input_audio_transcription.completed", + ), + ); + check( + "canonical completion is authoritative and restores ready UI state", + input.getText() === "Hello" && + editable() && + status.local.at(-1).label === "Dictation ready.", + ); + socket.message({ + ...firstHypothesis, + event_id: "unknown_hypothesis_without_active_take", + item_id: "orphan_item", + transcript: "ORPHAN", + }); + check( + "an unknown hypothesis with no active take changes no text", + input.getText() === "Hello", + ); + + const second = await startTake(); + check("a second take starts on the reusable fixture socket", second === socket); + wire.fire.inputCancelled("fixture"); + const clear = socket.sent + .filter((event) => event.type === "input_audio_buffer.clear") + .at(-1); + check( + "second-take cleanup sends the canonical clear event", + clear?.type === + canonicalMessage( + "clear_retires_only_uncommitted_input", + "client", + "input_audio_buffer.clear", + ).type, + ); + check("second-take cleanup stops recording", !status.recording); + check("second-take cleanup preserves completed text", input.getText() === "Hello"); + check("second-take cleanup restores the no-wait disabled UI state", !editable()); + dispose(); + } + + // A mismatched acknowledgment retires its provisional take and unblocks FIFO. + + { + const { wire, status, mic, input, startTake, dispose } = await harness(); + wire.fire.inputRequired("tok"); + const socket = await startTake(); + if (socket === null) { + failures.push("mismatch recovery: the first take did not start"); + dispose(); + return; + } + socket.message({ + type: "conversation.item.input_audio_transcription.hypothesis", + event_id: "mismatch_hypothesis", + item_id: "provisional_item", + content_index: 0, + revision: 1, + transcript: "must roll back", + finalized: "", + agreed: "", + tentative: "must roll back", + audio_start_ms: 0, + audio_end_ms: 100, + }); + mic.click(); + await waitFor( + () => + socket.sent.filter((event) => event.type === "input_audio_buffer.commit").length === 1, + ); + socket.message({ + type: "input_audio_buffer.committed", + event_id: "mismatched_commit", + item_id: "wrong_item", + previous_item_id: null, + }); + check( + "a mismatched acknowledgment rolls back its provisional take", + input.getText() === "" && + status.local.at(-1).severity === "error" && + status.local.at(-1).label.includes("temporarily unavailable"), + ); + + await startTake(); + socket.message({ + type: "conversation.item.input_audio_transcription.hypothesis", + event_id: "fresh_hypothesis", + item_id: "fresh_item", + content_index: 0, + revision: 1, + transcript: "fresh take", + finalized: "fresh", + agreed: "", + tentative: " take", + audio_start_ms: 100, + audio_end_ms: 200, + }); + mic.click(); + await waitFor( + () => + socket.sent.filter((event) => event.type === "input_audio_buffer.commit").length === 2, + ); + socket.message({ + type: "input_audio_buffer.committed", + event_id: "fresh_commit", + item_id: "fresh_item", + previous_item_id: "wrong_item", + }); + socket.message({ + type: "conversation.item.input_audio_transcription.completed", + event_id: "fresh_completed", + item_id: "fresh_item", + content_index: 0, + transcript: "fresh final", + usage: { type: "duration", seconds: 0.1 }, + }); + check( + "one mismatch cannot block the next take's matching acknowledgment", + input.getText() === "fresh final" && + status.local.at(-1).label === "Dictation ready.", + ); + dispose(); + } + // --- The pinned wait gates the mic; a dying wait discards the take ------- { @@ -295,7 +621,7 @@ await assertNoLeaks(lifecycle, async () => { mic.querySelector("svg") !== null, ); const gated = await startTake(); - check("a mic click with no wait pinned opens no /stt socket", gated === null); + check("a mic click with no wait pinned opens no Realtime socket", gated === null); check( "a gated click names the missing wait on the status bar", status.local.length === 1 && @@ -306,7 +632,7 @@ await assertNoLeaks(lifecycle, async () => { wire.fire.inputRequired("tok1"); const socket = await startTake(); - check("the mic click opens a /stt socket once a wait is pinned", socket !== null); + check("the mic click opens a Realtime socket once a wait is pinned", socket !== null); if (socket === null) { dispose(); return; @@ -320,7 +646,7 @@ await assertNoLeaks(lifecycle, async () => { wire.fire.inputCancelled("tok1"); check("a cancelled wait dims the recording LED", !status.recording); - check("a cancelled wait closes the take's /stt socket", socket.closed); + check("a cancelled wait keeps the reusable Realtime socket open", !socket.closed); check( "a cancelled wait lifts the take lock and drops the interim", !recording() && input.getText() === "", @@ -332,15 +658,15 @@ await assertNoLeaks(lifecycle, async () => { wire.fire.inputRequired("tok2"); const reopened = await startTake(); check("a fresh wait lets the mic start a fresh take", reopened !== null); - reopened?.close(); - check("a dropped /stt socket dims the recording LED", !status.recording); + wire.fire.inputCancelled("tok2"); + check("clearing the second take dims the recording LED", !status.recording); // A new session resets the pin: the take dies with it. wire.fire.inputRequired("tok3"); const third = await startTake(); check("a take starts against the third wait", third !== null); wire.fire.session("s2"); - check("a new session discards the live take", third?.closed === true && !status.recording && !recording()); + check("a new session discards the live take", third?.closed === false && !status.recording && !recording()); dispose(); const before = sockets.length; @@ -356,7 +682,7 @@ await assertNoLeaks(lifecycle, async () => { wire.fire.inputRequired("tok1"); const socket = await startTake(); if (socket === null) { - failures.push("wait swap: the mic click did not open a /stt socket"); + failures.push("wait swap: the mic click did not open a Realtime socket"); dispose(); return; } @@ -381,7 +707,7 @@ await assertNoLeaks(lifecycle, async () => { wire.fire.inputRequired("tok"); const socket = await startTake(); if (socket === null) { - failures.push("interim splice: the mic click did not open a /stt socket"); + failures.push("interim splice: the mic click did not open a Realtime socket"); dispose(); return; } @@ -406,6 +732,203 @@ await assertNoLeaks(lifecycle, async () => { dispose(); } + // Producer-generated ownership snapshots replay through replacement verbatim. + + { + const { wire, input, startTake, dispose } = await harness(); + wire.fire.inputRequired("producer"); + const socket = await startTake(); + if (socket === null) { + failures.push("producer replay: the mic click did not open a Realtime socket"); + dispose(); + return; + } + const first = canonicalMessage( + "producer_hypothesis_ownership", + "server", + "conversation.item.input_audio_transcription.hypothesis", + ); + const second = canonicalMessage( + "producer_hypothesis_ownership", + "server", + "conversation.item.input_audio_transcription.hypothesis", + 1, + ); + socket.message(first); + check( + "producer ownership replay lands one exact transcript without a duplicated prefix", + input.getText() === "ask not your country new tail first", + ); + socket.message(second); + check( + "producer ownership revision preserves exact spaces while replacing", + input.getText() === "ask not your country new tail second", + ); + dispose(); + } + + // Standalone producer transcripts compose only at the logical document end. + + { + const { wire, mic, input, editable, startTake, dispose } = await harness(); + wire.fire.inputRequired("sequential"); + const socket = await startTake(); + if (socket === null) { + failures.push("sequential composition: the first take did not start"); + dispose(); + return; + } + socket.message(producerHypothesis("composition_first", "First test alpha")); + mic.click(); + await waitFor( + () => + socket.sent.filter((event) => event.type === "input_audio_buffer.commit").length === 1, + ); + socket.message(producerCommitted("composition_first")); + socket.message(producerCompletion("composition_first", "First test alpha")); + + await startTake(); + socket.message(producerHypothesis("composition_second", "Second test beta")); + check( + "a second standalone producer hypothesis composes after the first take", + input.getText() === "First test alpha Second test beta", + ); + mic.click(); + await waitFor( + () => + socket.sent.filter((event) => event.type === "input_audio_buffer.commit").length === 2, + ); + socket.message(producerCommitted("composition_second")); + socket.message(producerCompletion("composition_second", "Second test beta")); + check( + "the standalone completion replaces its hypothesis without losing composition spacing", + input.getText() === "First test alpha Second test beta" && editable(), + ); + dispose(); + } + + { + const { wire, mic, input, startTake, dispose } = await harness(); + wire.fire.inputRequired("completion-only"); + input.setText("First test alpha"); + const socket = await startTake(); + if (socket === null) { + failures.push("completion-only composition: the take did not start"); + dispose(); + return; + } + mic.click(); + await waitFor(() => + socket.sent.some((event) => event.type === "input_audio_buffer.commit"), + ); + socket.message(producerCommitted("completion_only_second")); + socket.message(producerCompletion("completion_only_second", "Second test beta")); + check( + "a completion with no hypothesis composes at the logical document end", + input.getText() === "First test alpha Second test beta", + ); + dispose(); + } + + { + const { wire, input, startTake, dispose } = await harness(); + wire.fire.inputRequired("existing-space"); + input.setText("First test alpha "); + const socket = await startTake(); + socket?.message(producerHypothesis("existing_space", "Second test beta")); + check( + "existing trailing space prevents an added composition separator", + input.getText() === "First test alpha Second test beta", + ); + dispose(); + } + + { + const { wire, input, startTake, dispose } = await harness(); + wire.fire.inputRequired("producer-space"); + input.setText("First test alpha"); + const socket = await startTake(); + socket?.message(producerHypothesis("producer_space", " Second test beta")); + check( + "producer-leading space prevents a duplicate composition separator", + input.getText() === "First test alpha Second test beta", + ); + dispose(); + } + + { + const { wire, input, startTake, dispose } = await harness(); + wire.fire.inputRequired("selection"); + input.setText("First test alpha"); + input.setSelection(7, 11); + const socket = await startTake(); + socket?.message(producerHypothesis("selection", "Second")); + check( + "a selected replacement receives no composition separator", + input.getText() === "First Second alpha", + ); + dispose(); + } + + { + const { wire, input, startTake, dispose } = await harness(); + wire.fire.inputRequired("mid-word"); + input.setText("alphaBeta"); + input.setSelection(6, 6); + const socket = await startTake(); + socket?.message(producerHypothesis("mid_word", "Second")); + check( + "a mid-word insertion receives no composition separator", + input.getText() === "alphaSecondBeta", + ); + dispose(); + } + + { + const { wire, input, startTake, dispose } = await harness(); + wire.fire.inputRequired("rollback-spacing"); + input.setText("First test alpha"); + const socket = await startTake(); + socket?.message(producerHypothesis("rollback_spacing", "Second test beta")); + wire.fire.inputCancelled("rollback-spacing"); + check( + "rolling back a composed hypothesis removes its owned separator", + input.getText() === "First test alpha", + ); + dispose(); + } + + // A take owns the selection present when delayed capture becomes usable. + + { + const { wire, status, mic, input, editable, recording, dispose } = await harness(); + wire.fire.inputRequired("delayed-start"); + input.setText("old target keep"); + input.setSelection(5, 11); + const delayed = delayNextMediaStart(); + mic.click(); + await delayed.requested; + check("the prompt remains editable during microphone startup", editable() && !recording()); + + input.setText("edited live tail"); + input.setSelection(8, 12); + delayed.release(); + const started = await waitFor(() => status.recording); + const socket = sockets.filter((candidate) => candidate.url.endsWith("/v1/realtime")).at(-1); + check("delayed microphone startup completes", started); + socket.message(producerHypothesis("delayed_prompt", "spoken")); + check( + "a delayed take inserts at the selection current when startup succeeds", + input.getText() === "edited spoken tail", + ); + wire.fire.inputCancelled("delayed-start"); + check( + "delayed prompt rollback preserves edits made during startup", + input.getText() === "edited live tail" && !recording(), + ); + dispose(); + } + // --- Takes insert at the cursor ------------------------------------------- { @@ -416,26 +939,27 @@ await assertNoLeaks(lifecycle, async () => { input.setSelection(2, 2); let socket = await startTake(); if (socket === null) { - failures.push("cursor insert: the mic click did not open a /stt socket"); + failures.push("cursor insert: the mic click did not open a Realtime socket"); dispose(); return; } socket.message({ type: "interim", committed: "X", tentative: "" }); check("an interim inserts at the cursor", input.getText() === "aXb"); + const afterInterim = input.insertionContext().range; check( "the cursor sits after the inserted interim", - input.getSelection().start === 3 && input.getSelection().end === 3, + afterInterim.start === 3 && afterInterim.end === 3, ); socket.message({ type: "final", text: "Y" }); check("the final replaces the interim in place", input.getText() === "aYb" && editable()); - check("the final closes the take's socket", socket.closed); + check("the final keeps the reusable Realtime socket open", !socket.closed); input.setText("ab"); input.setSelection(1, 3); socket = await startTake(); socket?.message({ type: "interim", committed: "X", tentative: "" }); check("a selection is replaced outright", input.getText() === "X"); - socket?.close(); + socket?.message({ type: "final", text: "X" }); input.setText("start"); socket = await startTake(); @@ -458,7 +982,7 @@ await assertNoLeaks(lifecycle, async () => { input.setText("prefix"); const socket = await startTake(); if (socket === null) { - failures.push("readonly take: the mic click did not open a /stt socket"); + failures.push("readonly take: the mic click did not open a Realtime socket"); dispose(); return; } @@ -468,7 +992,11 @@ await assertNoLeaks(lifecycle, async () => { check("the interim still lands programmatically", input.getText() === "prefix world"); // Stopping through the mic sends "stop" and waits for the final. mic.click(); - check("a second mic click sends stop", socket.sent.includes("stop")); + await waitFor(() => socket.sent.some((event) => event.type === "input_audio_buffer.commit")); + check( + "a second mic click sends the canonical commit event", + socket.sent.some((event) => event.type === "input_audio_buffer.commit"), + ); check("the take lock holds until the final arrives", !editable()); socket.message({ type: "final", text: " world" }); check("the final lifts the take lock", editable() && !recording()); @@ -483,7 +1011,7 @@ await assertNoLeaks(lifecycle, async () => { wire.fire.inputRequired("tok1"); let socket = await startTake(); if (socket === null) { - failures.push("stop window: the mic click did not open a /stt socket"); + failures.push("stop window: the mic click did not open a Realtime socket"); dispose(); return; } @@ -491,7 +1019,7 @@ await assertNoLeaks(lifecycle, async () => { mic.click(); check("the stop dims the recording LED while the final is awaited", !status.recording && !editable()); wire.fire.inputCancelled("tok1"); - check("a wait dying in the stop window closes the awaited socket", socket.closed); + check("a wait dying in the stop window keeps the Realtime session reusable", !socket.closed); check( "a wait dying in the stop window lifts the take lock and drops the interim", !recording() && input.getText() === "", @@ -505,8 +1033,8 @@ await assertNoLeaks(lifecycle, async () => { mic.click(); send.click(); check( - "a send in the stop window carries the interim and closes the awaited socket", - isDeepStrictEqual(wire.responses, [["tok2", "sent as shown"]]) && socket?.closed === true, + "a send in the stop window carries the interim", + isDeepStrictEqual(wire.responses, [["tok2", "sent as shown"]]) && socket?.closed === false, ); check( "a send in the stop window lifts the take lock and clears the box", @@ -527,7 +1055,36 @@ await assertNoLeaks(lifecycle, async () => { ); check( "a socket dropping in the stop window says so on the status bar", - status.local.some((entry) => entry.label.includes("before the final transcript") && entry.severity === "error"), + status.local.some((entry) => entry.label.includes("temporarily unavailable") && entry.severity === "error"), + ); + dispose(); + } + + // A stop keeps routing the worklet's carried block until flush completes. + + { + const { wire, mic, startTake, dispose } = await harness(); + wire.fire.inputRequired("tok"); + const socket = await startTake(); + if (socket === null) { + failures.push("flush ordering: the mic click did not open a Realtime socket"); + dispose(); + return; + } + nextFlushAudio = Uint8Array.from([1, 0, 2, 0]).buffer; + mic.click(); + await waitFor(() => + socket.sent.some((event) => event.type === "input_audio_buffer.commit"), + ); + const speechEvents = socket.sent.filter((event) => + event.type.startsWith("input_audio_buffer."), + ); + check( + "stop sends the worklet's carried PCM block before commit", + speechEvents.length === 2 && + speechEvents[0].type === "input_audio_buffer.append" && + speechEvents[0].audio === "AQACAA==" && + speechEvents[1].type === "input_audio_buffer.commit", ); dispose(); } @@ -539,7 +1096,7 @@ await assertNoLeaks(lifecycle, async () => { wire.fire.inputRequired("tok1"); const socket = await startTake(); if (socket === null) { - failures.push("discard on send: the mic click did not open a /stt socket"); + failures.push("discard on send: the mic click did not open a Realtime socket"); dispose(); return; } @@ -548,7 +1105,7 @@ await assertNoLeaks(lifecycle, async () => { send.click(); check("the send carries the interim the operator saw", isDeepStrictEqual(wire.responses, [["tok1", "hello"]])); check("the send dims the recording LED", !status.recording); - check("the send closes the take's /stt socket", socket.closed); + check("the send keeps the reusable Realtime socket open", !socket.closed); check( "the send lifts the take lock and clears the box", !recording() && input.getText() === "", @@ -566,46 +1123,266 @@ await assertNoLeaks(lifecycle, async () => { ); check( "Enter during a take discards it and sends the interim", - isDeepStrictEqual(wire.responses[1], ["tok2", "via enter"]) && second?.closed === true && !status.recording, + isDeepStrictEqual(wire.responses[1], ["tok2", "via enter"]) && second?.closed === false && !status.recording, + ); + dispose(); + } + + // A discarded commit keeps its FIFO place until its acknowledgment arrives. + + { + const { wire, mic, input, startTake, dispose } = await harness(); + wire.fire.inputRequired("tok1"); + const socket = await startTake(); + if (socket === null) { + failures.push("commit tombstone: the first take did not start"); + dispose(); + return; + } + mic.click(); + await waitFor( + () => + socket.sent.filter((event) => event.type === "input_audio_buffer.commit").length === 1, + ); + wire.fire.inputCancelled("tok1"); + + wire.fire.inputRequired("tok2"); + await startTake(); + socket.message({ + type: "input_audio_buffer.committed", + event_id: "late_discarded_commit", + item_id: "discarded_item", + previous_item_id: null, + }); + socket.message({ + type: "conversation.item.input_audio_transcription.hypothesis", + event_id: "late_discarded_hypothesis", + item_id: "discarded_item", + content_index: 0, + revision: 1, + transcript: "WRONG TAKE", + finalized: "", + agreed: "", + tentative: "WRONG TAKE", + audio_start_ms: 0, + audio_end_ms: 100, + }); + check( + "a discarded commit's late acknowledgment and hypothesis do not bind the new take", + input.getText() === "", + ); + + mic.click(); + await waitFor( + () => + socket.sent.filter((event) => event.type === "input_audio_buffer.commit").length === 2, + ); + socket.message({ + type: "conversation.item.input_audio_transcription.hypothesis", + event_id: "current_hypothesis", + item_id: "current_item", + content_index: 0, + revision: 1, + transcript: "right take", + finalized: "right", + agreed: "", + tentative: " take", + audio_start_ms: 100, + audio_end_ms: 200, + }); + socket.message({ + type: "input_audio_buffer.committed", + event_id: "current_commit", + item_id: "current_item", + previous_item_id: "discarded_item", + }); + check( + "a precommit hypothesis after a tombstone binds the current take", + input.getText() === "right take", ); dispose(); } - // --- The capability probe gates the mic ------------------------------------ + // --- Overlapping items finalize independently ------------------------------ - for (const [capability, expected, name] of [ - [{ gpu: false, engine: true }, "needs a GPU", "no GPU"], - [{ gpu: true, engine: false }, "No speech models", "no engine"], - [null, "capability probe failed", "a failed probe"], - ]) { - const { wire, status, startTake, dispose } = await harness(capability); + { + const { wire, mic, input, editable, startTake, dispose } = await harness(); wire.fire.inputRequired("tok"); + input.setText("base "); const socket = await startTake(); - check(`${name} blocks the take with no /stt socket`, socket === null); + mic.click(); + await waitFor( + () => + socket.sent.filter((event) => event.type === "input_audio_buffer.commit").length === 1, + ); + socket.message({ + type: "conversation.item.input_audio_transcription.hypothesis", + event_id: "overlap_hypothesis_a", + item_id: "item_overlap_a", + content_index: 0, + revision: 1, + transcript: "first", + finalized: "fir", + agreed: "s", + tentative: "t", + audio_start_ms: 0, + audio_end_ms: 100, + }); + socket.message( + canonicalMessage( + "overlapping_items_reverse_completion", + "server", + "input_audio_buffer.committed", + ), + ); + + await startTake(); + mic.click(); + await waitFor( + () => + socket.sent.filter((event) => event.type === "input_audio_buffer.commit").length === 2, + ); + socket.message({ + type: "conversation.item.input_audio_transcription.hypothesis", + event_id: "overlap_hypothesis_b", + item_id: "item_overlap_b", + content_index: 0, + revision: 1, + transcript: " second", + finalized: " sec", + agreed: "on", + tentative: "d", + audio_start_ms: 100, + audio_end_ms: 200, + }); + socket.message( + canonicalMessage( + "overlapping_items_reverse_completion", + "server", + "input_audio_buffer.committed", + 1, + ), + ); check( - `${name} names its reason on the status bar`, - status.local.length === 1 && status.local[0].label.includes(expected) && status.local[0].severity === "info", + "overlapping hypotheses occupy isolated replacement regions", + input.getText() === "base first second" && !editable(), + ); + + for (const completion of [0, 1]) { + socket.message(canonicalMessage( + "overlapping_items_reverse_completion", + "server", + "conversation.item.input_audio_transcription.completed", + completion, + )); + } + check( + "reverse completion replaces each item with authoritative text", + input.getText() === "base first second" && editable(), ); dispose(); } - // A click that beats the probe is refused, not let through on the wait - // alone: a server with no engine still accepts /stt, so the gate must - // hold until the answer is known. Once it arrives, the same click starts a take. + // A correlated rejection rolls back only the client event's take. + { - const { wire, status, startTake, dispose } = await harness("pending"); + const { wire, mic, input, startTake, dispose } = await harness(); wire.fire.inputRequired("tok"); - const early = await startTake(); - check("a click while the probe is in flight opens no /stt socket", early === null); + const socket = await startTake(); + if (socket === null) { + failures.push("correlated error: the first take did not start"); + dispose(); + return; + } + mic.click(); + await waitFor( + () => + socket.sent.filter((event) => event.type === "input_audio_buffer.commit").length === 1, + ); + socket.message({ + type: "input_audio_buffer.committed", + event_id: "older_commit", + item_id: "older_item", + previous_item_id: null, + }); + socket.message({ + type: "conversation.item.input_audio_transcription.hypothesis", + event_id: "older_hypothesis", + item_id: "older_item", + content_index: 0, + revision: 1, + transcript: "older", + finalized: "old", + agreed: "", + tentative: "er", + audio_start_ms: 0, + audio_end_ms: 100, + }); + + await startTake(); + mic.click(); + await waitFor( + () => + socket.sent.filter((event) => event.type === "input_audio_buffer.commit").length === 2, + ); + const rejectedCommit = socket.sent + .filter((event) => event.type === "input_audio_buffer.commit") + .at(-1); + socket.message({ + type: "error", + event_id: "rejected_commit", + error: { + type: "invalid_request_error", + code: "audio_too_short", + message: "SERVER WORDING MUST NOT LEAK", + param: "audio", + event_id: rejectedCommit.event_id, + }, + }); check( - "a click while the probe is in flight says the check is still running", - status.local.length === 1 && status.local[0].label.includes("still checking") && status.local[0].severity === "info", + "a commit rejection rolls back its take but preserves an older finalization", + typeof rejectedCommit.event_id === "string" && input.getText() === "older", ); - answerPendingProbe({ gpu: true, engine: true }); - await sleep(10); + socket.message({ + type: "conversation.item.input_audio_transcription.completed", + event_id: "older_completed", + item_id: "older_item", + content_index: 0, + transcript: "OLDER FINAL", + usage: { type: "duration", seconds: 0.1 }, + }); + check( + "the preserved older item still accepts authoritative completion", + input.getText() === "OLDER FINAL", + ); + dispose(); + } + + // --- Recoverable Realtime errors use local wording ------------------------- + + { + const { wire, status, input, startTake, dispose } = await harness(); + wire.fire.inputRequired("tok"); const socket = await startTake(); - check("the gate lifts once the probe answers capable", socket !== null); - socket?.close(); + socket?.message({ type: "interim", committed: "temporary", tentative: "" }); + socket?.message({ + type: "error", + event_id: "server_error", + error: { + type: "server_error", + code: "engine_replaced", + message: "SERVER WORDING MUST NOT LEAK", + }, + }); + check( + "a recoverable server error restores the pre-take text", + input.getText() === "", + ); + check( + "a recoverable server error is worded locally", + status.local.at(-1).label.includes("temporarily unavailable") && + !status.local.at(-1).label.includes("SERVER WORDING"), + ); dispose(); } }); diff --git a/crates/workshop-server/ui/test/helpers/boot.mjs b/crates/workshop-server/ui/test/helpers/boot.mjs index 492975fd..f82fc33a 100644 --- a/crates/workshop-server/ui/test/helpers/boot.mjs +++ b/crates/workshop-server/ui/test/helpers/boot.mjs @@ -77,6 +77,20 @@ export async function bootWorkbench(name, run) { // `window.WebSocket`. Frames a test wants answered are pushed through // the socket's own onmessage by the ctx helpers below. const sockets = []; + const realtimeSession = (include, prompt = "") => ({ + id: "boot_realtime", + object: "realtime.transcription_session", + type: "transcription", + audio: { + input: { + format: { type: "audio/pcm", rate: 24000 }, + noise_reduction: null, + transcription: { model: "realtime-transcribe", prompt }, + turn_detection: null, + }, + }, + include, + }); class FakeWebSocket { static CONNECTING = 0; static OPEN = 1; @@ -90,6 +104,15 @@ export async function bootWorkbench(name, run) { setTimeout(() => { this.readyState = FakeWebSocket.OPEN; this.onopen?.(); + if (this.url.endsWith("/v1/realtime")) { + this.onmessage?.({ + data: JSON.stringify({ + type: "session.created", + event_id: "boot_realtime_created", + session: realtimeSession([]), + }), + }); + } }, 0); } addEventListener(type, listener) { @@ -99,6 +122,21 @@ export async function bootWorkbench(name, run) { } send(data) { this.sent.push(data); + const event = typeof data === "string" ? JSON.parse(data) : null; + if (event?.type === "session.update") { + queueMicrotask(() => + this.onmessage?.({ + data: JSON.stringify({ + type: "session.updated", + event_id: "boot_realtime_updated", + session: realtimeSession( + ["item.input_audio_transcription.hypothesis"], + event.session.audio.input.transcription.prompt, + ), + }), + }), + ); + } } close() { this.readyState = FakeWebSocket.CLOSED; @@ -115,6 +153,7 @@ export async function bootWorkbench(name, run) { }; class FakeAudioContext { constructor() { + this.sampleRate = 24_000; this.destination = {}; this.audioWorklet = { addModule: () => Promise.resolve() }; } @@ -124,10 +163,20 @@ export async function bootWorkbench(name, run) { close() { return Promise.resolve(); } + resume() { + return Promise.resolve(); + } } class FakeAudioWorkletNode { constructor() { - this.port = { onmessage: null }; + this.port = { + onmessage: null, + postMessage: (message) => { + if (message?.type === "flush") { + queueMicrotask(() => this.port.onmessage?.({ data: { type: "flushed" } })); + } + }, + }; } connect() {} disconnect() {} @@ -138,10 +187,9 @@ export async function bootWorkbench(name, run) { // The workbench state (models, profiles, selection) arrives only over // the socket, so a booted workbench fetches nothing but the Workshop - // tree's roots listing (answered empty: no grants yet) and the agent - // session's STT capability probe (answered fully capable, so a test - // can start a take). Any other fetch - including the retired /v1/models - // and /profiles boot fetches - rejects the test. + // tree's roots listing (answered empty: no grants yet). Any other fetch, + // including the retired /v1/models and /profiles boot fetches, rejects + // the test. globalThis.fetch = (url) => { if (url === "/workspace/tree") { return Promise.resolve( @@ -151,14 +199,6 @@ export async function bootWorkbench(name, run) { }), ); } - if (url === "/stt/capability") { - return Promise.resolve( - new Response(JSON.stringify({ gpu: true, engine: true }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - } return Promise.reject(new Error(`unexpected fetch in a booted workbench test: ${url}`)); }; @@ -251,10 +291,10 @@ export async function bootWorkbench(name, run) { // agent panel's /agents/ws connection. const wsSocket = () => sockets.filter((socket) => socket.url.endsWith("/ws") && !socket.url.endsWith("/agents/ws")).at(-1); - // The agent panel's session socket, and the per-take /stt sockets the - // mic opens. + // The agent panel's session socket, and the per-take Realtime sockets + // the mic opens. const agentsSocket = () => sockets.filter((socket) => socket.url.endsWith("/agents/ws")).at(-1); - const sttSockets = () => sockets.filter((socket) => socket.url.endsWith("/stt")); + const sttSockets = () => sockets.filter((socket) => socket.url.endsWith("/v1/realtime")); // The fake socket flips to OPEN on a 0ms timer, and the app can boot // during the bundle import's own microtask drain - before any macrotask diff --git a/crates/workshop-server/ui/test/pcm-worklet.mjs b/crates/workshop-server/ui/test/pcm-worklet.mjs new file mode 100644 index 00000000..7c4789f0 --- /dev/null +++ b/crates/workshop-server/ui/test/pcm-worklet.mjs @@ -0,0 +1,117 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import vm from "node:vm"; +import { fileURLToPath } from "node:url"; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const uiDir = path.join(testDir, ".."); +const fixturePath = path.join( + uiDir, + "..", + "..", + "gateway-stt", + "tests", + "fixtures", + "audio", + "pcm16le-24khz.json", +); + +async function loadProcessor( + name = "pcm16-capture", + options = {}, + outputSampleRate = 24_000, +) { + const source = await readFile(path.join(uiDir, "pcm-worklet.js"), "utf8"); + const processors = new Map(); + const messages = []; + const port = { + onmessage: null, + postMessage(value, transfer) { + messages.push({ value, transfer }); + }, + }; + const context = vm.createContext({ + sampleRate: outputSampleRate, + AudioWorkletProcessor: class { + constructor() { + this.port = port; + } + }, + registerProcessor(name, constructor) { + assert.equal(processors.has(name), false, `${name} is registered once`); + processors.set(name, constructor); + }, + }); + new vm.Script(source, { filename: "pcm-worklet.js" }).runInContext(context); + assert.deepEqual([...processors.keys()], ["pcm16-capture"]); + const Processor = processors.get(name); + assert.ok(Processor, `the real worklet registers ${name}`); + return { processor: new Processor(options), messages, port }; +} + +function bytesOf(buffer) { + return [...new Uint8Array(buffer)]; +} + +test("the real worklet emits the shared fixture as exact little-endian PCM16", async () => { + const fixture = JSON.parse(await readFile(fixturePath, "utf8")); + assert.equal(fixture.encoding, "pcm_s16le"); + assert.equal(fixture.sample_rate_hz, 24_000); + assert.equal(fixture.channels, 1); + await assert.rejects( + () => loadProcessor("pcm16-capture", {}, 16_000), + /24 kHz/, + "the PCM16 processor rejects a graph with the wrong output rate", + ); + + const { processor, messages } = await loadProcessor( + "pcm16-capture", + { processorOptions: { chunkSamples: fixture.samples.length } }, + ); + const floats = fixture.samples.map((sample) => + sample < 0 ? sample / 32_768 : sample / 32_767, + ); + processor.process([[Float32Array.from(floats.slice(0, 3))]]); + assert.equal(messages.length, 0, "a partial block is carried"); + processor.process([[Float32Array.from(floats.slice(3))]]); + + assert.equal(messages.length, 1); + assert.equal(Object.prototype.toString.call(messages[0].value), "[object ArrayBuffer]"); + assert.equal(messages[0].transfer.length, 1); + assert.equal(messages[0].transfer[0], messages[0].value); + assert.deepEqual(bytesOf(messages[0].value), fixture.bytes); +}); + +test("the real worklet clips samples and flushes only the carried partial block", async () => { + const { processor, messages, port } = await loadProcessor( + "pcm16-capture", + { processorOptions: { chunkSamples: 4 } }, + ); + processor.process([[Float32Array.from([-2, 2, -0.5, 0.5, 0.25])]]); + + assert.deepEqual(bytesOf(messages[0].value), [0, 128, 255, 127, 0, 192, 0, 64]); + assert.equal(messages.length, 1); + port.onmessage({ data: { type: "flush" } }); + assert.deepEqual(bytesOf(messages[1].value), [0, 32]); + assert.equal(messages[2].value.type, "flushed"); + port.onmessage({ data: { type: "flush" } }); + assert.equal(messages[3].value.type, "flushed"); +}); + +test("clear resets carried PCM16 before the next flush", async () => { + const { processor, messages, port } = await loadProcessor( + "pcm16-capture", + { processorOptions: { chunkSamples: 4 } }, + ); + + processor.process([[Float32Array.from([-0.5, 0.5])]]); + port.onmessage({ data: { type: "clear" } }); + processor.process([[Float32Array.from([0.25])]]); + port.onmessage({ data: { type: "flush" } }); + + assert.equal(messages.length, 2); + assert.deepEqual(bytesOf(messages[0].value), [0, 32]); + assert.equal(messages[1].value.type, "flushed"); +}); diff --git a/crates/workshop-server/ui/test/prompt-input.mjs b/crates/workshop-server/ui/test/prompt-input.mjs index 687e5db7..4eb31859 100644 --- a/crates/workshop-server/ui/test/prompt-input.mjs +++ b/crates/workshop-server/ui/test/prompt-input.mjs @@ -284,15 +284,20 @@ await assertNoLeaks(lifecycle, () => { input.setText("ab"); check("setText loads plain text", input.getText() === "ab"); input.setSelection(2, 2); + const middle = input.insertionContext(); check( - "setSelection places the cursor between the characters", - input.getSelection().start === 2 && input.getSelection().end === 2, + "insertionContext captures a mid-word cursor with no composition prefix", + middle.range.start === 2 && + middle.range.end === 2 && + middle.original === "" && + middle.compositionPrefix === "", ); input.replaceRange(2, 2, "X"); check("replaceRange splices at the cursor", input.getText() === "aXb"); + const afterInsert = input.insertionContext().range; check( "replaceRange leaves the cursor after the inserted text", - input.getSelection().start === 3 && input.getSelection().end === 3, + afterInsert.start === 3 && afterInsert.end === 3, ); input.replaceRange(1, 4, ""); check("replaceRange with empty text deletes the range", input.getText() === ""); @@ -305,6 +310,40 @@ await assertNoLeaks(lifecycle, () => { input.dispose(); } + { + const input = new PromptInput(); + input.setText("First test alpha"); + const append = input.insertionContext(); + check( + "insertionContext captures a ProseMirror append separator", + append.range.start === append.range.end && + append.range.end === 17 && + append.original === "" && + append.compositionPrefix === " ", + ); + input.replaceRange(append.range.start, append.range.end, " "); + check( + "a captured ProseMirror composition prefix is immutable", + append.compositionPrefix === " ", + ); + input.setText("First test alpha "); + check( + "insertionContext preserves existing ProseMirror trailing whitespace", + input.insertionContext().compositionPrefix === "", + ); + input.setText("First test alpha"); + input.setSelection(7, 11); + const replacement = input.insertionContext(); + check( + "insertionContext captures selected ProseMirror text without a separator", + replacement.range.start === 7 && + replacement.range.end === 11 && + replacement.original === "test" && + replacement.compositionPrefix === "", + ); + input.dispose(); + } + // --- Newlines cross the target seam --------------------------------------------- { @@ -325,9 +364,10 @@ await assertNoLeaks(lifecycle, () => { ); // The take's splice math (TakeState.length in stt.ts) holds only while // every inserted character, newline included, occupies one position. + const afterNewline = input.insertionContext().range; check( "a spliced newline occupies one position, keeping the take's length arithmetic", - input.getSelection().start === 5 && input.getSelection().end === 5, + afterNewline.start === 5 && afterNewline.end === 5, ); input.replaceRange(2, 5, ""); check( diff --git a/crates/workshop-server/ui/test/realtime-wire-fixtures.mjs b/crates/workshop-server/ui/test/realtime-wire-fixtures.mjs new file mode 100644 index 00000000..f790c431 --- /dev/null +++ b/crates/workshop-server/ui/test/realtime-wire-fixtures.mjs @@ -0,0 +1,570 @@ +import assert from "node:assert/strict"; +import { readFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import * as esbuild from "esbuild"; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +const decoderBundle = await esbuild.build({ + entryPoints: [path.join(testDir, "..", "src", "services", "realtime-event-decoder.ts")], + bundle: true, + write: false, + format: "esm", + platform: "browser", + target: "es2022", + logLevel: "silent", +}); +const { decodeRealtimeEvent } = await import( + `data:text/javascript;base64,${Buffer.from(decoderBundle.outputFiles[0].text).toString("base64")}` +); +const fixtureDir = path.join( + testDir, + "..", + "..", + "..", + "gateway-stt", + "tests", + "fixtures", + "realtime", +); + +const fixtureFiles = [ + "client-events.json", + "effective-sessions.json", + "invalid-sequences.json", + "server-events.json", + "valid-sequences.json", +]; + +const clientCases = [ + "input_audio_buffer_append", + "input_audio_buffer_clear", + "input_audio_buffer_commit", + "session_update", +]; + +const serverCases = [ + "conversation_item_created", + "error_correlated", + "error_minimal", + "error_uncorrelated", + "input_audio_buffer_cleared", + "input_audio_buffer_committed", + "session_created", + "session_updated", + "transcription_completed", + "transcription_delta", + "transcription_failed", + "transcription_hypothesis", +]; + +const validSequenceCases = [ + "clear_retires_only_uncommitted_input", + "configuration_snapshot_isolation", + "durable_lineage", + "engine_replacement", + "first_event_readiness", + "hypothesis_negotiation", + "immediate_commit_and_provisional_promotion", + "optional_client_ids_and_error_correlation", + "overlapping_items_reverse_completion", + "pending_precommit_failure_clear", + "pending_precommit_failure_commit", + "producer_hypothesis_ownership", + "saturated_commit_retry", + "segment_admission_failure", + "standard_delta_after_item_creation", +]; + +const invalidSequenceCases = [ + "append_after_precommit_failure", + "append_invalid_base64", + "append_limit_exceeded", + "append_unknown_field", + "clear_unknown_field", + "commit_short_audio", + "commit_unknown_field", + "dangling_pcm_byte_on_commit", + "excessive_queue_lag", + "invalid_client_event_id", + "invalid_include_type", + "invalid_prompt_type", + "malformed_json", + "maximum_committed_items", + "maximum_unfinalized_audio", + "missing_append_audio", + "missing_client_event_type", + "missing_session", + "missing_session_type", + "non_null_noise_reduction", + "non_null_turn_detection", + "result_queue_overload", + "session_audio_unknown_field", + "session_input_unknown_field", + "session_transcription_unknown_field", + "session_unknown_field", + "session_update_unknown_field", + "unknown_event_type", + "unknown_include", + "unsupported_delay", + "unsupported_format_rate", + "unsupported_format_type", + "unsupported_keywords", + "unsupported_language", + "unsupported_logprobs", + "unsupported_model", + "wrong_session_type", +]; + +const minimumCommitAudioBytes = (24_000 * 2) / 10; + +async function fixture(name) { + const parsed = JSON.parse(await readFile(path.join(fixtureDir, name), "utf8")); + assert.deepEqual(JSON.parse(JSON.stringify(parsed)), parsed, `${name} round-trips`); + return parsed; +} + +function sortedKeys(value) { + return Object.keys(value).sort(); +} + +function assertExactKeys(value, expected, context) { + assert.deepEqual(sortedKeys(value), [...expected].sort(), `${context} strict keys`); +} + +function assertNonemptyString(value, context) { + assert.equal(typeof value, "string", `${context} is a string`); + assert.notEqual(value.length, 0, `${context} is nonempty`); +} + +function fieldPaths(value, prefix = []) { + if (typeof value !== "object" || value === null) return []; + if (Array.isArray(value)) { + return value.flatMap((entry, index) => fieldPaths(entry, [...prefix, index])); + } + return Object.entries(value).flatMap(([key, entry]) => [ + [...prefix, key], + ...fieldPaths(entry, [...prefix, key]), + ]); +} + +function objectPaths(value, prefix = []) { + if (typeof value !== "object" || value === null) return []; + if (Array.isArray(value)) { + return value.flatMap((entry, index) => objectPaths(entry, [...prefix, index])); + } + return [ + prefix, + ...Object.entries(value).flatMap(([key, entry]) => + objectPaths(entry, [...prefix, key]), + ), + ]; +} + +function parentAt(value, path) { + return path.slice(0, -1).reduce((parent, segment) => parent[segment], value); +} + +function valueAt(value, path) { + return path.reduce((entry, segment) => entry[segment], value); +} + +function pathName(path) { + return path.map(String).join("."); +} + +function isOptionalErrorField(path) { + return ( + path.length >= 2 && + path.at(-2) === "error" && + (path.at(-1) === "param" || path.at(-1) === "event_id") + ); +} + +function assertSession(session, context) { + assertExactKeys(session, ["audio", "id", "include", "object", "type"], context); + assertNonemptyString(session.id, `${context}.id`); + assert.equal(session.object, "realtime.transcription_session"); + assert.equal(session.type, "transcription"); + assert.ok(Array.isArray(session.include) && session.include.length <= 1); + if (session.include.length === 1) { + assert.equal(session.include[0], "item.input_audio_transcription.hypothesis"); + } + assertExactKeys(session.audio, ["input"], `${context}.audio`); + const input = session.audio.input; + assertExactKeys( + input, + ["format", "noise_reduction", "transcription", "turn_detection"], + `${context}.audio.input`, + ); + assert.equal(input.noise_reduction, null); + assert.equal(input.turn_detection, null); + assert.deepEqual(input.format, { type: "audio/pcm", rate: 24000 }); + assertExactKeys(input.transcription, ["model", "prompt"], `${context}.transcription`); + assert.equal(input.transcription.model, "realtime-transcribe"); + assert.equal(typeof input.transcription.prompt, "string"); +} + +function assertError(event, correlation, context) { + assertExactKeys(event, ["error", "event_id", "type"], context); + assertNonemptyString(event.event_id, `${context}.event_id`); + assert.equal(event.type, "error"); + for (const field of Object.keys(event.error)) { + assert.ok(["code", "event_id", "message", "param", "type"].includes(field)); + } + for (const field of ["type", "code", "message"]) { + assertNonemptyString(event.error[field], `${context}.error.${field}`); + } + if ("param" in event.error) { + assert.ok(event.error.param === null || typeof event.error.param === "string"); + } + if (correlation === undefined) { + assert.equal("event_id" in event.error, false); + } else { + assert.equal(event.error.event_id, correlation); + } +} + +function canonicalBase64ByteLength(value, context) { + assert.equal(typeof value, "string", `${context} Base64 is a string`); + assert.match( + value, + /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/, + `${context} is canonical Base64`, + ); + const decoded = Buffer.from(value, "base64"); + assert.equal(decoded.toString("base64"), value, `${context} round-trips as Base64`); + return decoded.length; +} + +function assertValidCommitAudio(name, events) { + let bufferedAudioBytes = 0; + for (const [index, entry] of events.entries()) { + const { message } = entry; + if (entry.direction === "client") { + if (message.type === "input_audio_buffer.append") { + bufferedAudioBytes += canonicalBase64ByteLength( + message.audio, + `${name}[${index}].message.audio`, + ); + } else if (message.type === "input_audio_buffer.commit") { + assert.ok( + bufferedAudioBytes >= minimumCommitAudioBytes, + `${name}[${index}] commits ${bufferedAudioBytes} PCM16 bytes, below 100 ms`, + ); + } + } else if ( + message.type === "input_audio_buffer.committed" || + message.type === "input_audio_buffer.cleared" + ) { + bufferedAudioBytes = 0; + } + } +} + +function assertServerEventFields(event, context) { + const fieldsByType = { + "session.created": ["event_id", "session", "type"], + "session.updated": ["event_id", "session", "type"], + "input_audio_buffer.committed": [ + "event_id", + "item_id", + "previous_item_id", + "type", + ], + "input_audio_buffer.cleared": ["event_id", "type"], + "conversation.item.created": ["event_id", "item", "previous_item_id", "type"], + "conversation.item.input_audio_transcription.delta": [ + "content_index", + "delta", + "event_id", + "item_id", + "type", + ], + "conversation.item.input_audio_transcription.completed": [ + "content_index", + "event_id", + "item_id", + "transcript", + "type", + "usage", + ], + "conversation.item.input_audio_transcription.failed": [ + "content_index", + "error", + "event_id", + "item_id", + "type", + ], + "conversation.item.input_audio_transcription.hypothesis": [ + "agreed", + "audio_end_ms", + "audio_start_ms", + "content_index", + "event_id", + "finalized", + "item_id", + "revision", + "tentative", + "transcript", + "type", + ], + error: ["error", "event_id", "type"], + }; + assertExactKeys(event, fieldsByType[event.type], context); + if (event.type === "session.created" || event.type === "session.updated") { + assertSession(event.session, `${context}.session`); + } + if ("content_index" in event) assert.equal(event.content_index, 0); + if ("previous_item_id" in event) { + assert.ok( + event.previous_item_id === null || + (typeof event.previous_item_id === "string" && event.previous_item_id.length > 0), + ); + } + if (event.type === "conversation.item.created") { + assertExactKeys(event.item, ["content", "id", "role", "status", "type"], `${context}.item`); + assertNonemptyString(event.item.id, `${context}.item.id`); + assert.equal(event.item.type, "message"); + assert.equal(event.item.status, "completed"); + assert.equal(event.item.role, "user"); + assert.deepEqual(event.item.content, [{ type: "input_audio", transcript: null }]); + } +} + +test("canonical Realtime event fixtures match the Rust case list unchanged", async () => { + assert.deepEqual((await readdir(fixtureDir)).sort(), fixtureFiles); + + const clients = await fixture("client-events.json"); + assert.deepEqual(sortedKeys(clients), clientCases); + assert.equal(clients.session_update.type, "session.update"); + assert.equal(clients.input_audio_buffer_append.type, "input_audio_buffer.append"); + assert.equal(clients.input_audio_buffer_commit.type, "input_audio_buffer.commit"); + assert.equal(clients.input_audio_buffer_clear.type, "input_audio_buffer.clear"); + assertExactKeys(clients.session_update, ["event_id", "session", "type"], "session update"); + assertExactKeys( + clients.input_audio_buffer_append, + ["audio", "event_id", "type"], + "append", + ); + assertExactKeys(clients.input_audio_buffer_commit, ["type"], "commit"); + assertExactKeys(clients.input_audio_buffer_clear, ["type"], "clear"); + assertExactKeys( + clients.session_update.session, + ["audio", "include", "type"], + "session update body", + ); + assert.equal(clients.session_update.session.type, "transcription"); + assertExactKeys(clients.session_update.session.audio, ["input"], "session update audio"); + assertExactKeys( + clients.session_update.session.audio.input, + ["format", "noise_reduction", "transcription", "turn_detection"], + "session update input", + ); + + const sessions = await fixture("effective-sessions.json"); + assert.deepEqual(sortedKeys(sessions), ["default", "updated"]); + assertSession(sessions.default, "default session"); + assertSession(sessions.updated, "updated session"); + + const servers = await fixture("server-events.json"); + assert.deepEqual(sortedKeys(servers), serverCases); + for (const [name, event] of Object.entries(servers)) { + assertNonemptyString(event.event_id, `${name}.event_id`); + assertNonemptyString(event.type, `${name}.type`); + assertServerEventFields(event, name); + } + assert.deepEqual(servers.session_created.session, sessions.default); + assert.deepEqual(servers.session_updated.session, sessions.updated); + assertError(servers.error_correlated, "client_bad_update", "correlated error"); + assertError(servers.error_minimal, undefined, "minimal error"); + assertError(servers.error_uncorrelated, null, "uncorrelated error"); + assert.deepEqual(sortedKeys(servers.transcription_completed.usage), ["seconds", "type"]); + assert.equal(servers.transcription_completed.usage.type, "duration"); + assert.ok(servers.transcription_completed.usage.seconds >= 0); + + const hypothesis = servers.transcription_hypothesis; + assert.equal( + hypothesis.finalized + hypothesis.agreed + hypothesis.tentative, + hypothesis.transcript, + ); + assert.ok(Number.isSafeInteger(hypothesis.revision) && hypothesis.revision >= 0); + assert.ok(hypothesis.audio_start_ms >= 0); + assert.ok(hypothesis.audio_end_ms >= hypothesis.audio_start_ms); +}); + +test("the production decoder rejects every canonical field mutation", async () => { + const servers = await fixture("server-events.json"); + for (const [name, event] of Object.entries(servers)) { + assert.deepEqual(decodeRealtimeEvent(event), event, `${name} decodes unchanged`); + for (const fieldPath of fieldPaths(event)) { + const mutated = structuredClone(event); + const original = valueAt(mutated, fieldPath); + parentAt(mutated, fieldPath)[fieldPath.at(-1)] = + typeof original === "number" ? Number.NaN : 7; + assert.equal( + decodeRealtimeEvent(mutated), + null, + `${name} rejects invalid ${pathName(fieldPath)}`, + ); + + if (!isOptionalErrorField(fieldPath)) { + const omitted = structuredClone(event); + delete parentAt(omitted, fieldPath)[fieldPath.at(-1)]; + assert.equal( + decodeRealtimeEvent(omitted), + null, + `${name} rejects missing ${pathName(fieldPath)}`, + ); + } + } + for (const objectPath of objectPaths(event)) { + const mutated = structuredClone(event); + valueAt(mutated, objectPath).unexpected = true; + assert.equal( + decodeRealtimeEvent(mutated), + null, + `${name} rejects unknown ${pathName(objectPath) || "event"} field`, + ); + } + } + + assert.equal( + decodeRealtimeEvent({ event_id: "evt_future", type: "response.created" }), + null, + "unsupported event types are rejected", + ); + + const semanticMutations = [ + ["empty event ID", "session_created", ["event_id"], ""], + ["empty session ID", "session_created", ["session", "id"], ""], + ["unknown include", "session_updated", ["session", "include"], ["unsupported"]], + ["empty item ID", "input_audio_buffer_committed", ["item_id"], ""], + [ + "empty nullable lineage ID", + "input_audio_buffer_committed", + ["previous_item_id"], + "", + ], + ["empty conversation item ID", "conversation_item_created", ["item", "id"], ""], + ["wrong content index", "transcription_completed", ["content_index"], 1], + ["negative revision", "transcription_hypothesis", ["revision"], -1], + ["fractional revision", "transcription_hypothesis", ["revision"], 1.5], + [ + "unsafe revision", + "transcription_hypothesis", + ["revision"], + Number.MAX_SAFE_INTEGER + 1, + ], + [ + "unequal transcript partition", + "transcription_hypothesis", + ["transcript"], + "different", + ], + [ + "negative audio span", + "transcription_hypothesis", + ["audio_start_ms"], + -1, + ], + [ + "reversed audio span", + "transcription_hypothesis", + ["audio_start_ms"], + 1251, + ], + [ + "negative completion usage", + "transcription_completed", + ["usage", "seconds"], + -0.01, + ], + [ + "non-finite completion usage", + "transcription_completed", + ["usage", "seconds"], + Number.POSITIVE_INFINITY, + ], + [ + "empty error correlation ID", + "error_correlated", + ["error", "event_id"], + "", + ], + ["empty nullable error param", "error_correlated", ["error", "param"], ""], + ]; + for (const [context, caseName, fieldPath, replacement] of semanticMutations) { + const mutated = structuredClone(servers[caseName]); + parentAt(mutated, fieldPath)[fieldPath.at(-1)] = replacement; + assert.equal(decodeRealtimeEvent(mutated), null, `${context} is rejected`); + } +}); + +test("canonical Realtime sequences cover every frozen contract path", async () => { + const valid = await fixture("valid-sequences.json"); + assert.deepEqual(sortedKeys(valid), validSequenceCases); + for (const [name, sequence] of Object.entries(valid)) { + assertExactKeys(sequence, ["events", "invariants"], name); + assert.ok(sequence.events.length > 0, `${name} has events`); + assert.ok(sequence.invariants.length > 0, `${name} has invariants`); + for (const entry of sequence.events) { + assertExactKeys(entry, ["direction", "message"], `${name} entry`); + assert.ok(entry.direction === "client" || entry.direction === "server"); + assertNonemptyString(entry.message.type, `${name} event type`); + if (entry.direction === "server" || "event_id" in entry.message) { + assertNonemptyString(entry.message.event_id, `${name} event ID`); + } + if (entry.direction === "server") { + assertServerEventFields(entry.message, `${name} server event`); + assert.deepEqual( + decodeRealtimeEvent(entry.message), + entry.message, + `${name} server event decodes`, + ); + } + } + assertValidCommitAudio(name, sequence.events); + } + assert.deepEqual( + valid.hypothesis_negotiation.events + .filter( + ({ message }) => + message.type === "conversation.item.input_audio_transcription.hypothesis", + ) + .map(({ message }) => message.revision), + [1, 2], + ); + + const invalid = await fixture("invalid-sequences.json"); + assert.deepEqual(sortedKeys(invalid), invalidSequenceCases); + for (const [name, sequence] of Object.entries(invalid)) { + assertExactKeys( + sequence, + [ + "effective_session_after", + "expected_error", + "input", + "keeps_connection_usable", + ], + name, + ); + assert.equal(sequence.keeps_connection_usable, true); + assert.ok( + sequence.effective_session_after === "default" || + sequence.effective_session_after === "updated", + ); + let correlation; + if ("message" in sequence.input) { + correlation = + typeof sequence.input.message.event_id === "string" + ? sequence.input.message.event_id + : sequence.input.message.event_id === undefined + ? undefined + : null; + } + assertError(sequence.expected_error, correlation, `${name} expected error`); + assert.ok("message" in sequence.input || "wire_text" in sequence.input); + } +}); diff --git a/crates/workshop-server/ui/test/speech-capture.mjs b/crates/workshop-server/ui/test/speech-capture.mjs new file mode 100644 index 00000000..8a5b9b37 --- /dev/null +++ b/crates/workshop-server/ui/test/speech-capture.mjs @@ -0,0 +1,448 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import * as esbuild from "esbuild"; +import { assertNoLeaks } from "./helpers/leak-check.mjs"; + +const uiDir = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const bundle = await esbuild.build({ + stdin: { + contents: ` + export * as lifecycle from "./src/base/lifecycle.ts"; + export { SpeechCaptureService } from "./src/services/speech-capture.ts"; + `, + resolveDir: uiDir, + loader: "ts", + }, + bundle: true, + write: false, + format: "esm", + platform: "browser", + target: "es2022", + logLevel: "silent", +}); +const { lifecycle, SpeechCaptureService } = await import( + `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString("base64")}` +); + +function installBrowser(options = {}) { + const resources = { + constraints: [], + contexts: [], + sources: [], + nodes: [], + tracks: [{ stops: 0 }, { stops: 0 }], + stream: null, + }; + const stream = { + getTracks: () => + resources.tracks.map((track) => ({ + stop() { + track.stops += 1; + }, + })), + }; + resources.stream = stream; + + class FakeAudioContext { + constructor(contextOptions) { + if (options.contextError) { + throw options.contextError; + } + this.options = contextOptions; + this.sampleRate = options.contextSampleRate ?? 24_000; + this.destination = { kind: "destination" }; + this.resumeCalls = 0; + this.closeCalls = 0; + this.audioWorklet = { + addModule: async (url) => { + this.moduleUrl = url; + if (options.moduleError) { + throw options.moduleError; + } + }, + }; + resources.contexts.push(this); + } + + createMediaStreamSource(receivedStream) { + assert.equal(receivedStream, stream); + const source = { + connects: [], + disconnects: 0, + connect(target) { + this.connects.push(target); + }, + disconnect() { + this.disconnects += 1; + }, + }; + resources.sources.push(source); + return source; + } + + async resume() { + this.resumeCalls += 1; + if (options.resumeError) { + throw options.resumeError; + } + } + + async close() { + this.closeCalls += 1; + if (options.closeError) { + throw options.closeError; + } + } + } + + class FakeAudioWorkletNode { + constructor(context, name) { + if (options.nodeError) { + throw options.nodeError; + } + this.context = context; + this.name = name; + this.connects = []; + this.disconnects = 0; + this.messages = []; + this.port = { + onmessage: null, + postMessage: (message) => { + this.messages.push(message); + if (message?.type === "flush" && options.autoFlush !== false) { + queueMicrotask(() => this.port.onmessage?.({ data: { type: "flushed" } })); + } + if ( + options.postMessageError && + (!options.postMessageType || options.postMessageType === message?.type) + ) { + throw options.postMessageError; + } + }, + }; + resources.nodes.push(this); + } + + connect(target) { + this.connects.push(target); + } + + disconnect() { + this.disconnects += 1; + } + + emit(bytes) { + this.port.onmessage?.({ data: Uint8Array.from(bytes).buffer }); + } + } + + const previous = { + navigator: Object.getOwnPropertyDescriptor(globalThis, "navigator"), + AudioContext: Object.getOwnPropertyDescriptor(globalThis, "AudioContext"), + AudioWorkletNode: Object.getOwnPropertyDescriptor(globalThis, "AudioWorkletNode"), + }; + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + mediaDevices: { + getUserMedia: async (constraints) => { + resources.constraints.push(constraints); + if (options.mediaError) { + throw options.mediaError; + } + if (options.mediaPromise) { + return options.mediaPromise; + } + return stream; + }, + }, + }, + }); + Object.defineProperty(globalThis, "AudioContext", { + configurable: true, + value: FakeAudioContext, + }); + Object.defineProperty(globalThis, "AudioWorkletNode", { + configurable: true, + value: FakeAudioWorkletNode, + }); + + return { + resources, + restore() { + for (const [name, descriptor] of Object.entries(previous)) { + if (descriptor) { + Object.defineProperty(globalThis, name, descriptor); + } else { + delete globalThis[name]; + } + } + }, + }; +} + +async function withBrowser(options, run) { + const browser = installBrowser(options); + try { + await run(browser.resources); + } finally { + browser.restore(); + } +} + +test("the default backend owns the complete 24 kHz capture lifecycle", async () => { + await assertNoLeaks(lifecycle, async () => { + await withBrowser({}, async (resources) => { + const service = new SpeechCaptureService(); + const audio = []; + service.onAudio((chunk) => audio.push(...new Uint8Array(chunk))); + + assert.deepEqual(await service.start(), { ok: true, kind: "started" }); + assert.equal(service.recording, true); + assert.deepEqual(resources.constraints, [ + { + audio: { + channelCount: 1, + sampleRate: 24_000, + echoCancellation: true, + noiseSuppression: true, + }, + }, + ]); + assert.equal(resources.contexts[0].options.sampleRate, 24_000); + assert.equal(resources.contexts[0].sampleRate, 24_000); + assert.equal(resources.contexts[0].moduleUrl, "/pcm-worklet.js"); + assert.equal(resources.contexts[0].resumeCalls, 1); + assert.equal(resources.nodes[0].name, "pcm16-capture"); + assert.deepEqual(resources.sources[0].connects, [resources.nodes[0]]); + assert.deepEqual(resources.nodes[0].connects, [resources.contexts[0].destination]); + + resources.nodes[0].emit([1, 2, 255]); + assert.deepEqual(audio, [1, 2, 255]); + assert.deepEqual(service.clear(), { ok: true, kind: "cleared" }); + assert.deepEqual(resources.nodes[0].messages, [{ type: "clear" }]); + assert.deepEqual(await service.start(), { + ok: false, + kind: "start-failed", + message: "speech capture is already active", + recoverable: true, + }); + + assert.deepEqual(await service.stop(), { ok: true, kind: "stopped" }); + assert.equal(service.recording, false); + assert.deepEqual(resources.nodes[0].messages, [{ type: "clear" }, { type: "flush" }]); + assert.equal(resources.sources[0].disconnects, 1); + assert.equal(resources.nodes[0].disconnects, 1); + assert.deepEqual( + resources.tracks.map((track) => track.stops), + [1, 1], + ); + assert.equal(resources.contexts[0].closeCalls, 1); + assert.equal(resources.nodes[0].port.onmessage, null); + assert.deepEqual(await service.stop(), { ok: true, kind: "stopped" }); + service.dispose(); + }); + }); +}); + +test("the default backend classifies permission, device, and graph start failures", async () => { + await assertNoLeaks(lifecycle, async () => { + for (const [mediaError, kind] of [ + [new DOMException("microphone denied", "NotAllowedError"), "permission-denied"], + [new DOMException("no microphone", "NotFoundError"), "device-unavailable"], + ]) { + await withBrowser({ mediaError }, async (resources) => { + const service = new SpeechCaptureService(); + assert.deepEqual(await service.start(), { + ok: false, + kind, + message: mediaError.message, + recoverable: true, + }); + assert.equal(service.recording, false); + assert.equal(resources.contexts.length, 0); + assert.deepEqual( + resources.tracks.map((track) => track.stops), + [0, 0], + ); + service.dispose(); + }); + } + + await withBrowser( + { moduleError: new Error("worklet load failed") }, + async (resources) => { + const service = new SpeechCaptureService(); + assert.deepEqual(await service.start(), { + ok: false, + kind: "start-failed", + message: "worklet load failed", + recoverable: true, + }); + assert.equal(service.recording, false); + assert.deepEqual( + resources.tracks.map((track) => track.stops), + [1, 1], + ); + assert.equal(resources.contexts[0].closeCalls, 1); + assert.equal(resources.sources.length, 0); + assert.equal(resources.nodes.length, 0); + service.dispose(); + }, + ); + + await withBrowser({ contextSampleRate: 48_000 }, async (resources) => { + const service = new SpeechCaptureService(); + assert.deepEqual(await service.start(), { + ok: false, + kind: "start-failed", + message: "browser opened audio at 48000 Hz instead of 24000 Hz", + recoverable: true, + }); + assert.deepEqual( + resources.tracks.map((track) => track.stops), + [1, 1], + ); + assert.equal(resources.contexts[0].closeCalls, 1); + service.dispose(); + }); + }); +}); + +test("stop and disposal release every production graph resource", async () => { + await assertNoLeaks(lifecycle, async () => { + await withBrowser({ closeError: new Error("context close failed") }, async (resources) => { + const service = new SpeechCaptureService(); + assert.deepEqual(await service.start(), { ok: true, kind: "started" }); + assert.deepEqual(await service.stop(), { + ok: false, + kind: "stop-failed", + message: "context close failed", + recoverable: true, + }); + assert.equal(service.recording, false); + assert.equal(resources.sources[0].disconnects, 1); + assert.equal(resources.nodes[0].disconnects, 1); + assert.deepEqual( + resources.tracks.map((track) => track.stops), + [1, 1], + ); + assert.equal(resources.contexts[0].closeCalls, 1); + assert.equal(resources.nodes[0].port.onmessage, null); + service.dispose(); + }); + + await withBrowser( + { postMessageError: new Error("worklet port failed") }, + async (resources) => { + const service = new SpeechCaptureService(); + assert.deepEqual(await service.start(), { ok: true, kind: "started" }); + assert.deepEqual(await service.stop(), { + ok: false, + kind: "stop-failed", + message: "worklet port failed", + recoverable: true, + }); + assert.equal(resources.sources[0].disconnects, 1); + assert.equal(resources.nodes[0].disconnects, 1); + assert.deepEqual( + resources.tracks.map((track) => track.stops), + [1, 1], + ); + assert.equal(resources.contexts[0].closeCalls, 1); + service.dispose(); + }, + ); + + await withBrowser( + { + postMessageError: new Error("clear failed"), + postMessageType: "clear", + }, + async () => { + const service = new SpeechCaptureService(); + assert.deepEqual(await service.start(), { ok: true, kind: "started" }); + assert.deepEqual(service.clear(), { + ok: false, + kind: "clear-failed", + message: "clear failed", + recoverable: true, + }); + assert.equal(service.recording, true); + assert.deepEqual(await service.stop(), { ok: true, kind: "stopped" }); + service.dispose(); + }, + ); + + await withBrowser({ autoFlush: false }, async (resources) => { + const service = new SpeechCaptureService(); + assert.deepEqual(await service.start(), { ok: true, kind: "started" }); + const stopping = service.stop(); + service.dispose(); + assert.deepEqual(await stopping, { + ok: false, + kind: "stop-failed", + message: "speech capture was disposed while flushing", + recoverable: true, + }); + assert.equal(resources.sources[0].disconnects, 1); + assert.equal(resources.nodes[0].disconnects, 1); + assert.deepEqual( + resources.tracks.map((track) => track.stops), + [1, 1], + ); + assert.equal(resources.contexts[0].closeCalls, 1); + }); + + await withBrowser({}, async (resources) => { + const service = new SpeechCaptureService(); + assert.deepEqual(await service.start(), { ok: true, kind: "started" }); + service.dispose(); + service.dispose(); + assert.equal(service.recording, false); + assert.equal(resources.sources[0].disconnects, 1); + assert.equal(resources.nodes[0].disconnects, 1); + assert.deepEqual( + resources.tracks.map((track) => track.stops), + [1, 1], + ); + assert.equal(resources.contexts[0].closeCalls, 1); + assert.equal(resources.nodes[0].port.onmessage, null); + }); + }); +}); + +test("disposal during production start rejects the take and leaks no graph", async () => { + let resolveMedia; + const mediaPromise = new Promise((resolve) => { + resolveMedia = resolve; + }); + + await assertNoLeaks(lifecycle, async () => { + await withBrowser({ mediaPromise }, async (resources) => { + const service = new SpeechCaptureService(); + const starting = service.start(); + await Promise.resolve(); + service.dispose(); + resolveMedia(resources.stream); + + assert.deepEqual(await starting, { + ok: false, + kind: "start-failed", + message: "speech capture was disposed while starting", + recoverable: true, + }); + assert.equal(service.recording, false); + assert.equal(resources.sources[0].disconnects, 1); + assert.equal(resources.nodes[0].disconnects, 1); + assert.deepEqual( + resources.tracks.map((track) => track.stops), + [1, 1], + ); + assert.equal(resources.contexts[0].closeCalls, 1); + assert.equal(resources.nodes[0].port.onmessage, null); + }); + }); +}); diff --git a/crates/workshop-server/ui/test/stt-capability.mjs b/crates/workshop-server/ui/test/stt-capability.mjs deleted file mode 100644 index a5ec08e2..00000000 --- a/crates/workshop-server/ui/test/stt-capability.mjs +++ /dev/null @@ -1,115 +0,0 @@ -// Unit test for the STT capability probe (src/ui/stt.ts -// sttCapability). Bundles the TS module with esbuild and drives it -// against scripted fetch responses: gpu/engine boolean combinations, -// non-OK status, network failure, and malformed bodies. The mic stays -// visible whatever the answer - the probe feeds the blocker reason the -// status bar names on click - so every failure mode must answer null. -// Run: node test/stt-capability.mjs -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import * as esbuild from "esbuild"; - -const uiDir = path.dirname(fileURLToPath(import.meta.url)); - -const bundle = await esbuild.build({ - entryPoints: [path.join(uiDir, "..", "src", "ui", "stt.ts")], - bundle: true, - write: false, - format: "esm", - platform: "browser", - target: "es2022", - loader: { ".css": "empty" }, - logLevel: "silent", -}); -const code = bundle.outputFiles[0].text; -const mod = await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`); -const { sttCapability } = mod; - -const failures = []; -function check(name, condition) { - if (!condition) failures.push(name); -} - -function jsonResponse(body, status = 200) { - return new Response(typeof body === "string" ? body : JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); -} - -async function withFetch(impl, run) { - const original = globalThis.fetch; - globalThis.fetch = impl; - try { - await run(); - } finally { - globalThis.fetch = original; - } -} - -await withFetch( - (url) => { - check("probe queries /stt/capability", url === "/stt/capability"); - return Promise.resolve(jsonResponse({ gpu: true, engine: true })); - }, - async () => { - const answer = await sttCapability(); - check( - "gpu and engine true answer both true", - answer !== null && answer.gpu === true && answer.engine === true, - ); - }, -); - -await withFetch( - () => Promise.resolve(jsonResponse({ gpu: false, engine: true })), - async () => { - const answer = await sttCapability(); - check( - "gpu false answers gpu false with the engine flag intact", - answer !== null && answer.gpu === false && answer.engine === true, - ); - }, -); - -await withFetch( - () => Promise.resolve(jsonResponse({ gpu: true, engine: false })), - async () => { - const answer = await sttCapability(); - check( - "engine false answers engine false with the gpu flag intact", - answer !== null && answer.gpu === true && answer.engine === false, - ); - }, -); - -await withFetch(() => Promise.resolve(jsonResponse({ gpu: "yes", engine: true })), async () => { - check("a non-boolean gpu answers null", (await sttCapability()) === null); -}); - -await withFetch(() => Promise.resolve(jsonResponse({ gpu: true })), async () => { - check("a missing engine field answers null", (await sttCapability()) === null); -}); - -await withFetch(() => Promise.resolve(jsonResponse({})), async () => { - check("a missing gpu field answers null", (await sttCapability()) === null); -}); - -await withFetch(() => Promise.resolve(jsonResponse("not json at all")), async () => { - check("an unparseable body answers null", (await sttCapability()) === null); -}); - -await withFetch(() => Promise.resolve(jsonResponse({ gpu: true, engine: true }, 500)), async () => { - check("a non-OK status answers null", (await sttCapability()) === null); -}); - -await withFetch(() => Promise.reject(new Error("connection refused")), async () => { - check("a network failure answers null", (await sttCapability()) === null); -}); - -if (failures.length > 0) { - console.error(`stt-capability test failed:\n- ${failures.join("\n- ")}`); - process.exit(1); -} -console.log("stt-capability test passed"); -process.exit(0); diff --git a/crates/workshop-server/ui/test/stt-stream.mjs b/crates/workshop-server/ui/test/stt-stream.mjs index 36867472..9c2c50bf 100644 --- a/crates/workshop-server/ui/test/stt-stream.mjs +++ b/crates/workshop-server/ui/test/stt-stream.mjs @@ -1,25 +1,22 @@ -// Stream-generation test for the STT client (src/ui/stt.ts, step 15): -// the server's `stream` frame announces the take's generation; interim and -// final frames tagged with an older generation are stale (a stop/restart -// race) and must be discarded; frames with no generation, or tagged frames -// arriving before any announcement, are treated as current so the client -// tolerates a server that never announces. Drives setupStt against a -// scripted fake WebSocket and stubbed audio in a jsdom DOM. -// Run: node test/stt-stream.mjs -import { writeFile } from "node:fs/promises"; -import os from "node:os"; +// Browser Realtime transcription service, pinned to the canonical wire +// fixtures shared with the Rust implementation. Run: node test/stt-stream.mjs +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { mock } from "node:test"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; +import { fileURLToPath } from "node:url"; import * as esbuild from "esbuild"; import { JSDOM } from "jsdom"; import { assertNoLeaks } from "./helpers/leak-check.mjs"; const uiDir = path.dirname(fileURLToPath(import.meta.url)); - +const fixtures = path.join(uiDir, "..", "..", "..", "gateway-stt", "tests", "fixtures", "realtime"); const bundle = await esbuild.build({ stdin: { contents: ` export * as lifecycle from "./src/base/lifecycle.ts"; + export { RealtimeTranscriptionService } from "./src/services/realtime-transcription.ts"; + export { SpeechCaptureService } from "./src/services/speech-capture.ts"; export { setupStt, textareaSttTarget } from "./src/ui/stt.ts"; `, resolveDir: path.join(uiDir, ".."), @@ -34,74 +31,81 @@ const bundle = await esbuild.build({ logLevel: "silent", }); -const bundlePath = path.join(os.tmpdir(), "gateway-stt-stream-test.mjs"); -await writeFile(bundlePath, bundle.outputFiles[0].text); -const { lifecycle, setupStt, textareaSttTarget } = await import(pathToFileURL(bundlePath).href); +const { + lifecycle, + RealtimeTranscriptionService, + SpeechCaptureService, + setupStt, + textareaSttTarget, +} = await import( + `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString("base64")}` +); +const client = JSON.parse(await readFile(path.join(fixtures, "client-events.json"), "utf8")); +const server = JSON.parse(await readFile(path.join(fixtures, "server-events.json"), "utf8")); +globalThis.location = new URL("http://127.0.0.1:7910/"); -const failures = []; -function check(name, condition) { - if (!condition) failures.push(name); -} +{ + const dom = new JSDOM(""); + const textarea = dom.window.document.querySelector("textarea"); + const target = textareaSttTarget(textarea); -// A DOM for the mic button and the composer textarea. The bundle reads the -// globals, so jsdom's window fills the gaps Node does not provide; Event -// comes from jsdom too, so dispatchEvent accepts what notifyInput builds. -const { window } = new JSDOM("", { - url: "http://127.0.0.1/", -}); -globalThis.document = window.document; -globalThis.location = window.location; -globalThis.Event = window.Event; -globalThis.window = window; - -// Audio stubs: the getUserMedia/AudioContext path is scripted to succeed, -// as in smoke.mjs - jsdom has no audio stack. -const fakeAudioStream = { getTracks: () => [{ stop() {} }] }; -globalThis.navigator.mediaDevices = { - getUserMedia: () => Promise.resolve(fakeAudioStream), -}; -class FakeAudioContext { - constructor() { - this.destination = {}; - this.audioWorklet = { addModule: () => Promise.resolve() }; - } - createMediaStreamSource() { - return { connect() {}, disconnect() {} }; - } - close() { - return Promise.resolve(); - } -} -class FakeAudioWorkletNode { - constructor() { - this.port = { onmessage: null }; - } - connect() {} - disconnect() {} + textarea.value = "First test alpha"; + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + const append = target.insertionContext(); + assert.deepEqual(append, { + range: { start: 16, end: 16 }, + original: "", + compositionPrefix: " ", + }); + + textarea.value += " "; + assert.equal( + append.compositionPrefix, + " ", + "a captured textarea composition prefix is immutable", + ); + textarea.setSelectionRange(textarea.value.length, textarea.value.length); + assert.equal( + target.insertionContext().compositionPrefix, + "", + "existing textarea whitespace prevents a composition separator", + ); + + textarea.value = "First test alpha"; + textarea.setSelectionRange(6, 10); + assert.deepEqual( + target.insertionContext(), + { + range: { start: 6, end: 10 }, + original: "test", + compositionPrefix: "", + }, + "a textarea selection is captured without a composition separator", + ); + + textarea.value = "alphaBeta"; + textarea.setSelectionRange(5, 5); + assert.deepEqual( + target.insertionContext(), + { + range: { start: 5, end: 5 }, + original: "", + compositionPrefix: "", + }, + "a mid-word textarea insertion receives no composition separator", + ); } -window.AudioContext = FakeAudioContext; -globalThis.AudioContext = FakeAudioContext; -globalThis.AudioWorkletNode = FakeAudioWorkletNode; - -// A scripted /stt socket. Opens asynchronously like a real one; the -// test drives server frames through message(). -const sockets = []; -class FakeWebSocket { + +class ScriptedSocket { static CONNECTING = 0; static OPEN = 1; static CLOSING = 2; static CLOSED = 3; constructor(url) { this.url = url; - this.readyState = FakeWebSocket.CONNECTING; - this.closed = false; + this.readyState = ScriptedSocket.CONNECTING; this.sent = []; this.listeners = new Map(); - sockets.push(this); - setTimeout(() => { - this.readyState = FakeWebSocket.OPEN; - this.dispatch("open", {}); - }, 0); } addEventListener(type, listener, options) { if (!this.listeners.has(type)) this.listeners.set(type, []); @@ -116,118 +120,637 @@ class FakeWebSocket { for (const entry of entries) entry.listener(event); } send(data) { - this.sent.push(data); + this.sent.push(JSON.parse(data)); } close() { - if (this.closed) return; - this.closed = true; - this.readyState = FakeWebSocket.CLOSED; + if (this.readyState === ScriptedSocket.CLOSED) return; + this.readyState = ScriptedSocket.CLOSED; this.dispatch("close", {}); } - // Test-side control, not part of the WebSocket surface. + open() { + this.readyState = ScriptedSocket.OPEN; + this.dispatch("open", {}); + } message(frame) { this.dispatch("message", { data: JSON.stringify(frame) }); } } -window.WebSocket = FakeWebSocket; -globalThis.WebSocket = FakeWebSocket; - -// startStt crosses several await points (getUserMedia, socket open, -// worklet load) before sending "start"; poll until the take is live. -async function waitFor(condition) { - for (let attempt = 0; attempt < 50; attempt++) { - if (condition()) return true; - await new Promise((resolve) => setTimeout(resolve, 5)); + +await assertNoLeaks(lifecycle, async () => { + const dom = new JSDOM(""); + const mic = dom.window.document.querySelector("button"); + const textarea = dom.window.document.querySelector("textarea"); + const previousEvent = globalThis.Event; + globalThis.Event = dom.window.Event; + try { + const socket = new ScriptedSocket("/v1/realtime"); + const realtime = new RealtimeTranscriptionService({ socket: () => socket }); + socket.open(); + socket.message(server.session_created); + socket.message(server.session_updated); + + let finishCaptureStart = null; + const capture = new SpeechCaptureService({ + open: () => + new Promise((resolve) => { + finishCaptureStart = () => + resolve({ + clear() {}, + async stop() {}, + dispose() {}, + }); + }), + }); + const status = { + recording: false, + showLocal() {}, + setRecording(recording) { + this.recording = recording; + }, + }; + const stt = setupStt( + { mic, input: textareaSttTarget(textarea) }, + status, + () => null, + capture, + realtime, + ); + + textarea.value = "old target keep"; + textarea.setSelectionRange(4, 10); + textarea.focus(); + mic.click(); + assert.equal(typeof finishCaptureStart, "function"); + assert.equal(textarea.readOnly, false, "the textarea remains editable during startup"); + + textarea.value = "edited live tail"; + textarea.setSelectionRange(7, 11); + finishCaptureStart(); + for (let turn = 0; turn < 4 && !status.recording; turn++) { + await Promise.resolve(); + } + assert.equal(textarea.readOnly, true, "successful startup locks the current textarea context"); + + socket.message({ + ...server.transcription_hypothesis, + event_id: "evt_delayed_textarea_hypothesis", + item_id: "item_delayed_textarea", + transcript: "spoken", + finalized: "", + agreed: "", + tentative: "spoken", + }); + assert.equal(textarea.value, "edited spoken tail"); + stt.discardIfRecording(); + assert.equal( + textarea.value, + "edited live tail", + "textarea rollback restores the selection captured after delayed startup", + ); + assert.equal(textarea.selectionStart, 11); + assert.equal(dom.window.document.activeElement, textarea); + + stt.dispose(); + capture.dispose(); + realtime.dispose(); + } finally { + globalThis.Event = previousEvent; + dom.window.close(); } - return false; -} +}); + +await assertNoLeaks(lifecycle, async () => { + const dom = new JSDOM(""); + const mic = dom.window.document.querySelector("button"); + const textarea = dom.window.document.querySelector("textarea"); + const previousEvent = globalThis.Event; + globalThis.Event = dom.window.Event; + try { + let nextEventId = 1; + const socket = new ScriptedSocket("/v1/realtime"); + const realtime = new RealtimeTranscriptionService({ + eventId: () => `client_once_${nextEventId++}`, + socket: () => socket, + }); + socket.open(); + socket.message(server.session_created); + socket.message(server.session_updated); -const statusBar = { showLocal() {}, setRecording() {} }; + const captureTrace = []; + const capture = new SpeechCaptureService({ + async open() { + return { + clear() { + captureTrace.push("clear"); + }, + async stop() { + captureTrace.push("stop"); + }, + dispose() {}, + }; + }, + }); + const status = { + local: [], + recording: [], + showLocal(label, severity) { + this.local.push({ label, severity }); + }, + setRecording(recording) { + this.recording.push(recording); + }, + }; + const stt = setupStt( + { mic, input: textareaSttTarget(textarea) }, + status, + () => null, + capture, + realtime, + ); + + mic.click(); + for (let turn = 0; turn < 4 && status.recording.at(-1) !== true; turn++) { + await Promise.resolve(); + } + captureTrace.length = 0; + status.local.length = 0; + status.recording.length = 0; + + socket.message(server.error_uncorrelated); + + assert.deepEqual(captureTrace, ["clear", "stop"]); + assert.deepEqual(status.recording, [false]); + assert.deepEqual(status.local, [ + { + label: "Dictation is temporarily unavailable. Try again.", + severity: "error", + }, + ]); + assert.equal( + socket.sent.filter( + (event) => event.type === "input_audio_buffer.clear", + ).length, + 1, + "one decoded failure produces one reducer-owned wire clear", + ); + + stt.dispose(); + capture.dispose(); + realtime.dispose(); + } finally { + globalThis.Event = previousEvent; + dom.window.close(); + } +}); await assertNoLeaks(lifecycle, async () => { - const mic = window.document.createElement("button"); - const input = window.document.createElement("textarea"); - window.document.body.append(mic, input); - const handle = setupStt({ mic, input: textareaSttTarget(input) }, statusBar, () => null); + const dom = new JSDOM(""); + const mic = dom.window.document.querySelector("button"); + const textarea = dom.window.document.querySelector("textarea"); + const previousEvent = globalThis.Event; + globalThis.Event = dom.window.Event; + try { + const trace = []; + const socket = new ScriptedSocket("/v1/realtime"); + const realtime = new RealtimeTranscriptionService({ + eventId: () => "client_loss_update", + socket: () => socket, + }); + socket.open(); + socket.message(server.session_created); + socket.message(server.session_updated); - // --- The stream frame sets the generation; matching frames apply ------- + const capture = new SpeechCaptureService({ + async open() { + return { + clear() { + trace.push("capture.clear"); + }, + async stop() { + trace.push("capture.stop"); + }, + dispose() {}, + }; + }, + }); + const status = { + showLocal(label) { + trace.push(`status.local:${label}`); + }, + setRecording(recording) { + trace.push(`status.recording:${recording}`); + }, + }; + const stt = setupStt( + { mic, input: textareaSttTarget(textarea) }, + status, + () => null, + capture, + realtime, + ); - mic.click(); - check( - "the mic click opens a /stt socket and sends start", - await waitFor(() => sockets.length === 1 && sockets[0].sent.includes("start")), - ); - const socket = sockets[0]; - socket.message({ type: "stream", generation: 2 }); - socket.message({ type: "interim", committed: "ask not", tentative: "", generation: 2 }); - check("a current-generation interim splices into the textarea", input.value === "ask not"); - - // --- Stale frames are discarded ----------------------------------------- - - socket.message({ type: "interim", committed: "STALE", tentative: "", generation: 1 }); - check("a stale interim is discarded", input.value === "ask not"); - socket.message({ type: "final", text: "stale final", frames: 1, generation: 1 }); - check("a stale final does not finish the take", input.value === "ask not" && input.readOnly); - check("a stale final does not close the socket", !socket.closed); - - // --- A missing generation is treated as current -------------------------- - - socket.message({ type: "interim", committed: "ask not what", tentative: "" }); - check("an interim with no generation is treated as current", input.value === "ask not what"); - socket.message({ type: "final", text: "ask not what you can do", frames: 64, generation: 2 }); - check( - "the current generation's final finishes the take", - input.value === "ask not what you can do" && !input.readOnly, - ); - check("the final closes the socket", socket.closed); + mic.click(); + for ( + let turn = 0; + turn < 4 && trace.at(-1) !== "status.local:Listening..."; + turn++ + ) { + await Promise.resolve(); + } + trace.length = 0; + const sentBeforeLoss = structuredClone(socket.sent); - // --- Tagged frames before any announcement are treated as current -------- + socket.close(); - input.value = ""; - input.setSelectionRange(0, 0); - mic.click(); - check( - "a second mic click opens a fresh /stt socket", - await waitFor(() => sockets.length === 2 && sockets[1].sent.includes("start")), - ); - sockets[1].message({ type: "interim", committed: "later take", tentative: "", generation: 5 }); - check( - "a tagged interim before any stream frame is treated as current", - input.value === "later take", - ); + assert.deepEqual(trace, [ + "capture.clear", + "capture.stop", + "status.recording:false", + "status.local:Dictation is temporarily unavailable. Try again.", + ]); + assert.deepEqual( + socket.sent, + sentBeforeLoss, + "connection loss cannot emit a clear on the already unavailable socket", + ); + + stt.dispose(); + capture.dispose(); + realtime.dispose(); + } finally { + globalThis.Event = previousEvent; + dom.window.close(); + } +}); + +await assertNoLeaks(lifecycle, async () => { + const sockets = []; + const service = new RealtimeTranscriptionService({ + prompt: "meeting notes", + eventId: (() => { + const ids = [ + "client_update_1", + "client_append_1", + "client_commit_1", + "client_clear_1", + ]; + return () => ids.shift(); + })(), + socket: (url) => { + const socket = new ScriptedSocket(url); + sockets.push(socket); + return socket; + }, + }); + const states = []; + const events = []; + const errors = []; + service.onState((value) => states.push(value)); + service.onEvent((value) => events.push(value)); + service.onError((value) => errors.push(value)); + for (const legacyCallback of [ + "onCommitted", + "onSnapshot", + "onCompleted", + "onFailed", + ]) { + assert.equal( + legacyCallback in service, + false, + `${legacyCallback} cannot retain callback-owned take state`, + ); + } - handle.dispose(); + assert.equal(sockets.length, 1); + assert.match(sockets[0].url, /\/v1\/realtime$/); + sockets[0].open(); + sockets[0].message(server.session_created); + assert.deepEqual(sockets[0].sent, [client.session_update]); + sockets[0].message(server.session_updated); + assert.equal(service.state, "ready"); + assert.deepEqual(states, ["ready"]); - // --- A blocked click names the reason and opens no socket --------------- + service.append(Uint8Array.from([0, 0, 1, 0, 255, 255]).buffer); + service.commit(); + service.clear(); + assert.deepEqual(sockets[0].sent.slice(1), [ + client.input_audio_buffer_append, + { ...client.input_audio_buffer_commit, event_id: "client_commit_1" }, + { ...client.input_audio_buffer_clear, event_id: "client_clear_1" }, + ]); - const blockedMic = window.document.createElement("button"); - const blockedInput = window.document.createElement("textarea"); - window.document.body.append(blockedMic, blockedInput); - const local = []; - const blockedHandle = setupStt( - { mic: blockedMic, input: textareaSttTarget(blockedInput) }, - { showLocal: (label, severity) => local.push({ label, severity }), setRecording() {} }, - () => "Dictation needs a GPU this server doesn't have.", + sockets[0].message(server.input_audio_buffer_committed); + sockets[0].message(server.transcription_hypothesis); + sockets[0].message(server.transcription_delta); + sockets[0].message(server.transcription_completed); + sockets[0].message(server.transcription_failed); + sockets[0].message(server.error_correlated); + assert.deepEqual( + events.map((event) => event.type), + [ + "session.created", + "session.updated", + "input_audio_buffer.committed", + "conversation.item.input_audio_transcription.hypothesis", + "conversation.item.input_audio_transcription.completed", + "conversation.item.input_audio_transcription.failed", + "error", + ], + "the production service publishes strict decoded events for reducer ownership", ); - blockedMic.click(); - await waitFor(() => local.length > 0); - check( - "a blocked click names the reason on the status bar", - local.length === 1 && - local[0].label.includes("needs a GPU") && - local[0].severity === "info", + assert.deepEqual( + errors, + [], + "decoded server failures publish only through the reducer event seam", ); - check( - "a blocked click opens no /stt socket", - sockets.length === 2, + + service.dispose(); + assert.equal(sockets[0].readyState, ScriptedSocket.CLOSED); +}); + +await assertNoLeaks(lifecycle, async () => { + const sockets = []; + const service = new RealtimeTranscriptionService({ + eventId: () => "client_decoder_update", + socket: (url) => { + const socket = new ScriptedSocket(url); + sockets.push(socket); + return socket; + }, + }); + const events = []; + const errors = []; + service.onEvent((value) => events.push(value)); + service.onError((value) => errors.push(value)); + + sockets[0].open(); + sockets[0].message(server.session_created); + sockets[0].message(server.session_updated); + events.length = 0; + sockets[0].message({ + ...server.input_audio_buffer_committed, + unexpected: true, + }); + sockets[0].message({ + ...server.transcription_completed, + content_index: 1, + }); + sockets[0].message({ + event_id: "evt_future", + type: "response.created", + }); + + assert.deepEqual(events, []); + assert.deepEqual( + errors, + Array.from({ length: 3 }, () => ({ + code: "invalid_server_event", + scope: "session", + eventId: null, + recoverable: true, + })), ); - blockedHandle.dispose(); + service.dispose(); }); -if (failures.length > 0) { - console.error(`stt-stream: ${failures.length} failure(s)`); - for (const failure of failures) console.error(` - ${failure}`); - process.exit(1); -} -console.log("stt-stream: all assertions passed"); -process.exit(0); +await assertNoLeaks(lifecycle, async () => { + mock.timers.enable({ apis: ["setTimeout"] }); + try { + const sockets = []; + const service = new RealtimeTranscriptionService({ + eventId: () => "client_fallback_update", + socket: (url) => { + const socket = new ScriptedSocket(url); + sockets.push(socket); + return socket; + }, + }); + const errors = []; + const events = []; + service.onEvent((value) => events.push(value)); + service.onError((value) => errors.push(value)); + + const fallbackSessionUpdated = { + ...server.session_updated, + session: { + ...server.session_updated.session, + include: [], + }, + }; + sockets[0].open(); + sockets[0].message(server.session_created); + sockets[0].message(fallbackSessionUpdated); + sockets[0].message({ + ...server.transcription_delta, + event_id: "evt_stale_delta_1", + item_id: "item_shared", + delta: "stale", + }); + sockets[0].message({ + ...server.transcription_delta, + event_id: "evt_stale_delta_2", + item_id: "item_shared", + delta: " prefix", + }); + + sockets[0].close(); + mock.timers.tick(1_000); + assert.equal(sockets.length, 2, "the fallback session reconnects deterministically"); + sockets[1].open(); + sockets[1].message(server.session_created); + sockets[1].message(fallbackSessionUpdated); + sockets[1].message({ + ...server.transcription_delta, + event_id: "evt_fresh_delta_1", + item_id: "item_shared", + delta: "fresh", + }); + sockets[1].message({ + ...server.transcription_delta, + event_id: "evt_isolated_delta_1", + item_id: "item_isolated", + delta: "other", + }); + sockets[1].message({ + ...server.transcription_delta, + event_id: "evt_fresh_delta_2", + item_id: "item_shared", + delta: " transcript", + }); + sockets[1].message({ + ...server.transcription_delta, + event_id: "evt_isolated_delta_2", + item_id: "item_isolated", + delta: " item", + }); + sockets[1].message({ + ...server.transcription_delta, + event_id: "evt_invalid_fallback_delta", + item_id: "item_invalid", + unexpected: true, + }); + sockets[1].message({ + ...server.transcription_completed, + event_id: "evt_fresh_completed", + item_id: "item_shared", + transcript: "fresh transcript", + }); + + sockets[1].message(server.session_updated); + sockets[1].message({ + ...server.transcription_delta, + event_id: "evt_ignored_after_negotiation", + item_id: "item_isolated", + delta: " ignored", + }); + sockets[1].message({ + ...server.transcription_hypothesis, + event_id: "evt_later_hypothesis", + item_id: "item_isolated", + transcript: "hypothesis wins", + finalized: "", + agreed: "", + tentative: "hypothesis wins", + }); + sockets[1].message({ + ...server.transcription_completed, + event_id: "evt_isolated_completed", + item_id: "item_isolated", + transcript: "hypothesis wins", + }); + + assert.deepEqual( + events + .filter( + (event) => + event.type === + "conversation.item.input_audio_transcription.delta", + ) + .map((event) => event.event_id), + [ + "evt_stale_delta_1", + "evt_stale_delta_2", + "evt_fresh_delta_1", + "evt_isolated_delta_1", + "evt_fresh_delta_2", + "evt_isolated_delta_2", + ], + "fallback deltas reach the reducer seam until hypotheses are negotiated", + ); + assert.deepEqual( + events + .filter( + (event) => + event.type === + "conversation.item.input_audio_transcription.hypothesis" || + event.type === + "conversation.item.input_audio_transcription.completed", + ) + .map((event) => [event.type, event.item_id, event.transcript]), + [ + [ + "conversation.item.input_audio_transcription.completed", + "item_shared", + "fresh transcript", + ], + [ + "conversation.item.input_audio_transcription.hypothesis", + "item_isolated", + "hypothesis wins", + ], + [ + "conversation.item.input_audio_transcription.completed", + "item_isolated", + "hypothesis wins", + ], + ], + "strict decoded events carry every take transition without service snapshots", + ); + assert.deepEqual(errors.at(-1), { + code: "invalid_server_event", + scope: "session", + eventId: null, + recoverable: true, + }); + + service.dispose(); + } finally { + mock.timers.reset(); + } +}); + +await assertNoLeaks(lifecycle, async () => { + mock.timers.enable({ apis: ["setTimeout"] }); + try { + let attempts = 0; + const service = new RealtimeTranscriptionService({ + socket: () => { + attempts += 1; + throw new Error("gateway is still starting"); + }, + }); + + assert.equal(service.state, "unavailable"); + assert.equal(attempts, 1); + const schedule = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000]; + for (const [index, delay] of schedule.entries()) { + mock.timers.tick(delay - 1); + assert.equal( + attempts, + index + 1, + `retry ${index + 1} does not run before its ${delay} ms delay`, + ); + mock.timers.tick(1); + assert.equal( + attempts, + index + 2, + `retry ${index + 1} runs at its ${delay} ms delay`, + ); + } + + service.dispose(); + mock.timers.tick(30_000); + assert.equal(attempts, 8, "disposal cancels the pending capped retry"); + } finally { + mock.timers.reset(); + } +}); + +await assertNoLeaks(lifecycle, async () => { + mock.timers.enable({ apis: ["setTimeout"] }); + try { + const sockets = []; + let attempts = 0; + const service = new RealtimeTranscriptionService({ + socket: (url) => { + attempts += 1; + if (attempts === 1) { + throw new Error("gateway is still starting"); + } + const socket = new ScriptedSocket(url); + sockets.push(socket); + return socket; + }, + }); + + mock.timers.tick(1000); + assert.equal(attempts, 2, "an initial failure reconnects without another mic click"); + sockets[0].open(); + sockets[0].message(server.session_created); + sockets[0].message(server.session_updated); + assert.equal(service.state, "ready"); + + sockets[0].close(); + mock.timers.tick(999); + assert.equal(attempts, 2, "readiness resets the reconnect delay to one second"); + mock.timers.tick(1); + assert.equal(attempts, 3, "an established connection reconnects on the reset delay"); + const racing = sockets[1]; + service.dispose(); + racing.dispatch("close", {}); + mock.timers.tick(30_000); + assert.equal(attempts, 3, "disposal cancels a reconnect even as its socket is created"); + } finally { + mock.timers.reset(); + } +}); diff --git a/crates/workshop-server/ui/test/take-registry-regressions.mjs b/crates/workshop-server/ui/test/take-registry-regressions.mjs new file mode 100644 index 00000000..05d3a889 --- /dev/null +++ b/crates/workshop-server/ui/test/take-registry-regressions.mjs @@ -0,0 +1,257 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as esbuild from "esbuild"; + +const uiDir = path.dirname(fileURLToPath(import.meta.url)); +const bundle = await esbuild.build({ + stdin: { + contents: ` + export { + createTakeRegistry, + reduceTakeRegistry, + } from "./src/ui/take-registry.ts"; + `, + resolveDir: path.join(uiDir, ".."), + loader: "ts", + }, + bundle: true, + write: false, + format: "esm", + platform: "browser", + target: "es2022", + logLevel: "silent", +}); + +const { createTakeRegistry, reduceTakeRegistry } = await import( + `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString("base64")}` +); + +function context(start, end = start, original = "", compositionPrefix = "") { + return { + range: { start, end }, + original, + compositionPrefix, + }; +} + +function reduce(state, input) { + return reduceTakeRegistry(state, input); +} + +function start(state, insertion) { + return reduce(state, { type: "user.start", context: insertion }); +} + +function stop(state) { + return reduce(state, { type: "user.stop" }); +} + +function finishCapture(state, takeId, ok = true) { + return reduce(state, { type: "capture.stopped", takeId, ok }); +} + +function stopAndCommit(state, eventId) { + const stopping = stop(state); + const stopEffect = stopping.effects.find( + (effect) => effect.domain === "capture" && effect.command === "stop", + ); + assert.ok(stopEffect); + const stopped = finishCapture(stopping.state, stopEffect.takeId); + const commit = stopped.effects.find( + (effect) => effect.domain === "wire" && effect.command === "commit", + ); + assert.ok(commit); + return reduce(stopped.state, { + type: "wire.result", + requestId: commit.requestId, + eventId, + }); +} + +function server(state, event) { + return reduce(state, { type: "server.event", event }); +} + +function committed(itemId) { + return { + type: "input_audio_buffer.committed", + event_id: `commit_${itemId}`, + item_id: itemId, + previous_item_id: null, + }; +} + +function hypothesis(itemId, transcript, revision = 1) { + return { + type: "conversation.item.input_audio_transcription.hypothesis", + event_id: `hypothesis_${itemId}_${revision}`, + item_id: itemId, + content_index: 0, + revision, + transcript, + finalized: "", + agreed: "", + tentative: transcript, + audio_start_ms: 0, + audio_end_ms: 100, + }; +} + +function completion(itemId, transcript) { + return { + type: "conversation.item.input_audio_transcription.completed", + event_id: `completion_${itemId}`, + item_id: itemId, + content_index: 0, + transcript, + usage: { type: "duration", seconds: 0.1 }, + }; +} + +function replacements(effects) { + return effects.filter( + (effect) => effect.domain === "editor" && effect.command === "replace", + ); +} + +test("captured coordinate width owns replacement and rollback independently of text length", () => { + let state = start(createTakeRegistry(), context(4, 9, "xy")).state; + + let result = server(state, hypothesis("selection", "spoken")); + assert.deepEqual(replacements(result.effects), [ + { + domain: "editor", + command: "replace", + from: 4, + to: 9, + text: "spoken", + }, + ]); + state = result.state; + + result = reduce(state, { type: "user.discard" }); + assert.deepEqual(replacements(result.effects), [ + { + domain: "editor", + command: "replace", + from: 4, + to: 10, + text: "xy", + }, + ]); +}); + +test("a precommit tombstone consumes its matching acknowledgment before the next take", () => { + let state = start(createTakeRegistry(), context(0)).state; + state = server(state, hypothesis("discarded", "temporary")).state; + state = stopAndCommit(state, "client_discarded").state; + state = reduce(state, { type: "user.discard" }).state; + + state = start(state, context(0)).state; + let result = server(state, committed("discarded")); + assert.equal(result.state.awaitingCommit.length, 0); + assert.equal(result.state.takes[0].itemId, null); + + state = stopAndCommit(result.state, "client_current").state; + state = server(state, committed("current")).state; + assert.equal(state.takes[0].itemId, "current"); + + result = server(state, hypothesis("current", "provisional")); + assert.equal(result.state.takes[0].text, "provisional"); + result = server(result.state, completion("current", "complete")); + assert.deepEqual(replacements(result.effects), [ + { + domain: "editor", + command: "replace", + from: 0, + to: 11, + text: "complete", + }, + ]); + assert.equal(result.state.takes.length, 0); +}); + +test("a mismatched capture completion cannot release the stopping take", () => { + const recording = start(createTakeRegistry(), context(0)).state; + const stopping = stop(recording); + const owner = stopping.effects.find( + (effect) => effect.domain === "capture" && effect.command === "stop", + ); + assert.ok(owner); + + const before = structuredClone(stopping.state); + const stale = finishCapture(stopping.state, owner.takeId + 100); + assert.deepEqual(stale.state, before); + assert.deepEqual(stale.effects, []); + + const owned = finishCapture(stale.state, owner.takeId); + assert.equal(owned.state.capture, "idle"); + assert.ok( + owned.effects.some( + (effect) => effect.domain === "wire" && effect.command === "commit", + ), + ); +}); + +test("a duplicate capture completion cannot recommit an older retained take", () => { + let state = start(createTakeRegistry(), context(0)).state; + let stopping = stop(state); + const firstOwner = stopping.effects.find( + (effect) => effect.domain === "capture" && effect.command === "stop", + ); + assert.ok(firstOwner); + state = finishCapture(stopping.state, firstOwner.takeId).state; + + state = start(state, context(0)).state; + stopping = stop(state); + const secondOwner = stopping.effects.find( + (effect) => effect.domain === "capture" && effect.command === "stop", + ); + assert.ok(secondOwner); + + const before = structuredClone(stopping.state); + const duplicate = finishCapture(stopping.state, firstOwner.takeId); + assert.deepEqual(duplicate.state, before); + assert.deepEqual(duplicate.effects, []); + + const owned = finishCapture(duplicate.state, secondOwner.takeId); + const commits = owned.effects.filter( + (effect) => effect.domain === "wire" && effect.command === "commit", + ); + assert.equal(commits.length, 1); + assert.equal(commits[0].takeId, secondOwner.takeId); +}); + +test("audio flushed while capture stops remains owned by the stopping take", () => { + let state = start(createTakeRegistry(), context(0)).state; + const stopping = stop(state); + const owner = stopping.effects.find( + (effect) => effect.domain === "capture" && effect.command === "stop", + ); + assert.ok(owner); + + const flushed = reduce(stopping.state, { + type: "capture.audio", + chunk: Uint8Array.from([1, 0, 2, 0]).buffer, + }); + const append = flushed.effects.find( + (effect) => effect.domain === "wire" && effect.command === "append", + ); + assert.ok(append); + assert.equal(append.takeId, owner.takeId); + + state = reduce(flushed.state, { + type: "wire.result", + requestId: append.requestId, + eventId: "flushed_append", + }).state; + const stopped = finishCapture(state, owner.takeId); + assert.ok( + stopped.effects.some( + (effect) => effect.domain === "wire" && effect.command === "commit", + ), + "the carried append is followed by commit after capture flushes", + ); +}); diff --git a/crates/workshop-server/ui/test/take-registry.mjs b/crates/workshop-server/ui/test/take-registry.mjs new file mode 100644 index 00000000..57876469 --- /dev/null +++ b/crates/workshop-server/ui/test/take-registry.mjs @@ -0,0 +1,474 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import * as esbuild from "esbuild"; + +const uiDir = path.dirname(fileURLToPath(import.meta.url)); +const bundle = await esbuild.build({ + stdin: { + contents: ` + export { + createTakeRegistry, + reduceTakeRegistry, + } from "./src/ui/take-registry.ts"; + `, + resolveDir: path.join(uiDir, ".."), + loader: "ts", + }, + bundle: true, + write: false, + format: "esm", + platform: "browser", + target: "es2022", + logLevel: "silent", +}); + +const { createTakeRegistry, reduceTakeRegistry } = await import( + `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString("base64")}` +); + +function context(start, end = start, original = "", compositionPrefix = "") { + return { + range: { start, end }, + original, + compositionPrefix, + }; +} + +function start(state, insertion) { + return reduceTakeRegistry(state, { type: "user.start", context: insertion }); +} + +function stopAndCommit(state, eventId) { + const stopping = reduceTakeRegistry(state, { type: "user.stop" }); + const stopEffect = stopping.effects.find( + (effect) => effect.domain === "capture" && effect.command === "stop", + ); + assert.ok(stopEffect, "stop emits a capture request"); + const stopped = reduceTakeRegistry(stopping.state, { + type: "capture.stopped", + takeId: stopEffect.takeId, + ok: true, + }); + const commit = stopped.effects.find( + (effect) => effect.domain === "wire" && effect.command === "commit", + ); + assert.ok(commit, "successful capture stop emits a commit"); + const sent = reduceTakeRegistry(stopped.state, { + type: "wire.result", + requestId: commit.requestId, + eventId, + }); + return { state: sent.state, effects: [...stopping.effects, ...stopped.effects, ...sent.effects] }; +} + +function server(state, event) { + return reduceTakeRegistry(state, { type: "server.event", event }); +} + +function committed(itemId, eventId = `commit_${itemId}`) { + return { + type: "input_audio_buffer.committed", + event_id: eventId, + item_id: itemId, + previous_item_id: null, + }; +} + +function hypothesis(itemId, transcript, revision = 1) { + return { + type: "conversation.item.input_audio_transcription.hypothesis", + event_id: `hypothesis_${itemId}_${revision}`, + item_id: itemId, + content_index: 0, + revision, + transcript, + finalized: "", + agreed: "", + tentative: transcript, + audio_start_ms: 0, + audio_end_ms: 100, + }; +} + +function completion(itemId, transcript) { + return { + type: "conversation.item.input_audio_transcription.completed", + event_id: `completion_${itemId}`, + item_id: itemId, + content_index: 0, + transcript, + usage: { type: "duration", seconds: 0.1 }, + }; +} + +function editorReplacements(effects) { + return effects.filter( + (effect) => effect.domain === "editor" && effect.command === "replace", + ); +} + +test("transition table replaces selections and gives completion authority", () => { + let state = createTakeRegistry(); + const transitions = [ + { + input: { type: "user.start", context: context(6, 10, "test") }, + replacements: [], + takeCount: 1, + }, + { + input: { type: "server.event", event: hypothesis("selection", "spoken") }, + replacements: [{ from: 6, to: 10, text: "spoken" }], + takeCount: 1, + }, + { + input: { type: "server.event", event: hypothesis("selection", "provisional", 2) }, + replacements: [{ from: 6, to: 12, text: "provisional" }], + takeCount: 1, + }, + { + input: { type: "server.event", event: completion("selection", "final ") }, + replacements: [{ from: 6, to: 17, text: "final" }], + takeCount: 0, + }, + ]; + + for (const row of transitions) { + const result = reduceTakeRegistry(state, row.input); + assert.deepEqual( + editorReplacements(result.effects).map(({ from, to, text }) => ({ from, to, text })), + row.replacements, + ); + assert.equal(result.state.takes.length, row.takeCount); + state = result.state; + } + + assert.ok( + reduceTakeRegistry(state, { + type: "server.event", + event: hypothesis("selection", "late"), + }).effects.length === 0, + "a retired item cannot rewrite its completed selection", + ); +}); + +test("precommit binding confirms matches and rolls back mismatches", () => { + let state = start(createTakeRegistry(), context(0)).state; + let result = server(state, hypothesis("provisional", "temporary")); + state = result.state; + assert.equal(state.takes[0].itemId, "provisional"); + + state = stopAndCommit(state, "client_commit").state; + result = server(state, committed("wrong")); + assert.deepEqual(editorReplacements(result.effects), [ + { + domain: "editor", + command: "replace", + from: 0, + to: 9, + text: "", + }, + ]); + assert.equal(result.state.takes.length, 0); + assert.deepEqual(result.state.retiredItemIds.sort(), ["provisional", "wrong"]); + assert.ok( + result.effects.some( + (effect) => + effect.domain === "status" && + effect.command === "local" && + effect.severity === "error", + ), + ); + + state = start(result.state, context(0)).state; + state = stopAndCommit(state, "client_commit_2").state; + state = server(state, hypothesis("fresh", "new")).state; + result = server(state, committed("fresh")); + assert.equal(result.state.takes[0].itemId, "fresh"); + assert.equal(editorReplacements(result.effects).length, 0); +}); + +test("commit tombstones consume late acknowledgments without stealing a new take", () => { + let state = start(createTakeRegistry(), context(0)).state; + state = stopAndCommit(state, "client_commit_1").state; + state = reduceTakeRegistry(state, { type: "user.discard" }).state; + + state = start(state, context(0)).state; + let result = server(state, committed("discarded")); + assert.equal(result.state.takes[0].itemId, null); + assert.ok(result.state.retiredItemIds.includes("discarded")); + result = server(result.state, hypothesis("discarded", "WRONG TAKE")); + assert.equal(editorReplacements(result.effects).length, 0); + + state = stopAndCommit(result.state, "client_commit_2").state; + state = server(state, hypothesis("current", "right take")).state; + result = server(state, committed("current")); + assert.equal(result.state.takes[0].itemId, "current"); + assert.equal(result.state.takes[0].text, "right take"); +}); + +test("duplicate acknowledgments preserve the next FIFO owner", () => { + let state = start(createTakeRegistry(), context(0)).state; + state = stopAndCommit(state, "commit_a").state; + state = server(state, committed("a")).state; + state = start(state, context(0)).state; + state = stopAndCommit(state, "commit_b").state; + + state = server(state, committed("a", "duplicate_a")).state; + assert.deepEqual(state.awaitingCommit, [{ takeId: 2, itemId: null }]); + const result = server(state, committed("b")); + assert.deepEqual( + result.state.takes.map((take) => take.itemId), + ["a", "b"], + ); +}); + +test("decoded delta events accumulate into replacement snapshots", () => { + let state = start(createTakeRegistry(), context(0)).state; + for (const [index, delta] of ["one", " two"].entries()) { + const result = server(state, { + type: "conversation.item.input_audio_transcription.delta", + event_id: `delta_${index}`, + item_id: "delta_item", + content_index: 0, + delta, + }); + state = result.state; + assert.equal(editorReplacements(result.effects)[0].text, index === 0 ? "one" : "one two"); + } +}); + +test("overlapping takes shift isolated regions and complete in reverse order", () => { + let state = start(createTakeRegistry(), context(5)).state; + state = stopAndCommit(state, "commit_a").state; + let result = server(state, hypothesis("a", "first")); + state = result.state; + state = server(state, committed("a")).state; + + state = start(state, context(10, 10, "", " ")).state; + state = stopAndCommit(state, "commit_b").state; + result = server(state, hypothesis("b", "second")); + state = server(result.state, committed("b")).state; + assert.deepEqual( + state.takes.map((take) => ({ itemId: take.itemId, from: take.from, text: take.text })), + [ + { itemId: "a", from: 5, text: "first" }, + { itemId: "b", from: 10, text: " second" }, + ], + ); + + result = server(state, completion("b", "second")); + state = result.state; + assert.deepEqual(editorReplacements(result.effects), [ + { + domain: "editor", + command: "replace", + from: 10, + to: 17, + text: " second", + }, + ]); + result = server(state, completion("a", "FIRST")); + assert.deepEqual(editorReplacements(result.effects), [ + { + domain: "editor", + command: "replace", + from: 5, + to: 10, + text: "FIRST", + }, + ]); + assert.equal(result.state.takes.length, 0); +}); + +test("rollback shifts later takes back and preserves their authority", () => { + let state = start(createTakeRegistry(), context(0)).state; + state = stopAndCommit(state, "commit_a").state; + state = server(state, hypothesis("a", "temporary")).state; + state = server(state, committed("a")).state; + state = start(state, context(9, 9, "", " ")).state; + state = stopAndCommit(state, "commit_b").state; + state = server(state, hypothesis("b", "kept")).state; + state = server(state, committed("b")).state; + + let result = server(state, { + type: "conversation.item.input_audio_transcription.failed", + event_id: "failure_a", + item_id: "a", + content_index: 0, + error: { + type: "transcription_error", + code: "failed", + message: "must stay local", + }, + }); + assert.deepEqual(editorReplacements(result.effects), [ + { + domain: "editor", + command: "replace", + from: 0, + to: 9, + text: "", + }, + ]); + assert.equal(result.state.takes[0].from, 0); + + result = server(result.state, completion("b", "KEPT")); + assert.deepEqual(editorReplacements(result.effects), [ + { + domain: "editor", + command: "replace", + from: 0, + to: 5, + text: " KEPT", + }, + ]); +}); + +test("sequential takes own exactly one composition separator", () => { + let state = start(createTakeRegistry(), context(16, 16, "", " ")).state; + state = stopAndCommit(state, "commit_first").state; + state = server(state, committed("first")).state; + let result = server(state, completion("first", "Second test beta")); + assert.equal(editorReplacements(result.effects)[0].text, " Second test beta"); + + state = start(result.state, context(32, 32, "", " ")).state; + result = server(state, hypothesis("second", " leading")); + assert.equal(editorReplacements(result.effects)[0].text, " leading"); + state = result.state; + result = server(state, completion("second", "authoritative ")); + assert.equal(editorReplacements(result.effects)[0].text, " authoritative"); +}); + +test("a reconnect rolls back live state and rejects the old session's late events", () => { + let state = start(createTakeRegistry(), context(4, 4, "", " ")).state; + state = server(state, hypothesis("old", "temporary")).state; + + let result = reduceTakeRegistry(state, { type: "connection.lost" }); + assert.deepEqual(editorReplacements(result.effects), [ + { + domain: "editor", + command: "replace", + from: 4, + to: 14, + text: "", + }, + ]); + assert.ok( + result.effects.some( + (effect) => effect.domain === "capture" && effect.command === "clear", + ), + ); + assert.ok(result.state.retiredItemIds.includes("old")); + const stopEffect = result.effects.find( + (effect) => effect.domain === "capture" && effect.command === "stop", + ); + assert.ok(stopEffect); + state = reduceTakeRegistry(result.state, { + type: "capture.stopped", + takeId: stopEffect.takeId, + ok: true, + }).state; + state = reduceTakeRegistry(state, { type: "connection.ready" }).state; + assert.equal(state.connection, "ready"); + + result = server(state, completion("old", "LATE")); + assert.equal(result.effects.length, 0); + state = start(result.state, context(4, 4, "", " ")).state; + result = server(state, hypothesis("new", "fresh")); + assert.equal(editorReplacements(result.effects)[0].text, " fresh"); +}); + +test("capture and wire effects are typed and correlated to their take", () => { + let result = start(createTakeRegistry(), context(0)); + let state = result.state; + assert.deepEqual(result.effects, [ + { domain: "editor", command: "read-only", readOnly: true }, + { domain: "status", command: "recording", recording: true }, + { + domain: "status", + command: "local", + label: "Listening...", + severity: "info", + }, + ]); + + result = reduceTakeRegistry(state, { + type: "capture.audio", + chunk: Uint8Array.from([1, 2]).buffer, + }); + state = result.state; + const append = result.effects[0]; + assert.equal(append.domain, "wire"); + assert.equal(append.command, "append"); + assert.equal(append.takeId, state.activeTakeId); + + result = reduceTakeRegistry(state, { + type: "wire.result", + requestId: append.requestId, + eventId: "append_event", + }); + state = result.state; + const error = { + type: "error", + event_id: "server_error", + error: { + type: "invalid_request_error", + code: "bad_audio", + message: "must not surface", + event_id: "append_event", + }, + }; + result = server(state, error); + assert.equal(result.state.takes.length, 0); + assert.ok( + result.effects.some( + (effect) => + effect.domain === "status" && + effect.command === "local" && + !effect.label.includes("must not surface"), + ), + ); +}); + +test("every transition preserves registry invariants without mutating its input", () => { + let state = createTakeRegistry(); + const inputs = [ + { type: "user.start", context: context(0) }, + { type: "capture.audio", chunk: new ArrayBuffer(2) }, + { type: "user.stop" }, + { type: "connection.lost" }, + { type: "connection.ready" }, + { type: "user.start", context: context(0) }, + { type: "user.discard" }, + ]; + + for (const input of inputs) { + const before = structuredClone(state); + const result = reduceTakeRegistry(state, input); + assert.deepEqual(state, before, `${input.type} mutated its input`); + assert.equal( + new Set(result.state.takes.map((take) => take.id)).size, + result.state.takes.length, + `${input.type} duplicated a take id`, + ); + assert.equal( + result.state.takes.filter((take) => take.id === result.state.activeTakeId).length, + result.state.activeTakeId === null ? 0 : 1, + `${input.type} left an invalid active take`, + ); + assert.equal( + new Set(result.state.retiredItemIds).size, + result.state.retiredItemIds.length, + `${input.type} duplicated a tombstoned item id`, + ); + for (let index = 1; index < result.state.takes.length; index += 1) { + assert.ok( + result.state.takes[index - 1].from <= result.state.takes[index].from, + `${input.type} left take regions out of order`, + ); + } + state = result.state; + } +}); diff --git a/crates/workshop/installer.nsi b/crates/workshop/installer.nsi index faab2748..5a2bef8b 100644 --- a/crates/workshop/installer.nsi +++ b/crates/workshop/installer.nsi @@ -482,7 +482,7 @@ Function RunMainBinary ${If} ${FileExists} "$INSTDIR\${MAINBINARYNAME}.exe" nsis_tauri_utils::RunAsUser "$INSTDIR\${MAINBINARYNAME}.exe" "" ${ElseIf} ${FileExists} "$INSTDIR\promptforge-gateway.exe" - nsis_tauri_utils::RunAsUser "$INSTDIR\promptforge-gateway.exe" "serve --browser" + nsis_tauri_utils::RunAsUser "$INSTDIR\promptforge-gateway.exe" "--browser" ${EndIf} FunctionEnd @@ -870,13 +870,13 @@ Section "-Finalize" !insertmacro DeleteComponentPayloadIfDeclined ${SecWorkshop} $INSTDIR\${MAINBINARYNAME}.exe ; Relaunch the gateway when the install stopped one and the component - ; stays installed. `serve --login` keeps the relaunch headless: no + ; stays installed. `--login` keeps the relaunch headless: no ; browser, no window. ${If} $GatewayWasRunning = 1 SectionGetFlags ${SecGateway} $0 IntOp $0 $0 & ${SF_SELECTED} ${If} $0 = ${SF_SELECTED} - nsis_tauri_utils::RunAsUser "$INSTDIR\promptforge-gateway.exe" "serve --login" + nsis_tauri_utils::RunAsUser "$INSTDIR\promptforge-gateway.exe" "--login" ${EndIf} ${EndIf} diff --git a/crates/workshop/src/gateway.rs b/crates/workshop/src/gateway.rs index 45c97b2e..3957200f 100644 --- a/crates/workshop/src/gateway.rs +++ b/crates/workshop/src/gateway.rs @@ -18,10 +18,13 @@ //! (`crate::menu`) is the only path that stops the gateway. use std::path::{Path, PathBuf}; +use std::sync::mpsc; use std::time::{Duration, Instant}; use anyhow::Context as _; -use shared_sidecar::{ConnectionFile, LaunchDecision, Resolution, SidecarError}; +use shared_sidecar::{ + ConnectionFile, LaunchDecision, Resolution, SidecarError, ValidatedConnection, +}; use workshop_server::Config; /// The sibling executable the shell launches, beside its own. @@ -39,6 +42,43 @@ const LAUNCH_TIMEOUT: Duration = Duration::from_secs(30); /// Delay between polls for the launched gateway's connection file. const POLL_INTERVAL: Duration = Duration::from_millis(25); +/// Healthy-sidecar supervision cadence. +const SUPERVISION_INTERVAL: Duration = Duration::from_secs(5); + +/// First delay after a failed re-resolution or relaunch. +const SUPERVISION_BASE_DELAY: Duration = Duration::from_millis(250); + +/// Ceiling on repeated sidecar recovery attempts. +const SUPERVISION_MAX_DELAY: Duration = Duration::from_secs(30); + +/// One sidecar liveness observation. +enum SupervisionProbe { + /// Another process already published a live replacement. + Replacement(ConnectionFile), + /// No live local Gateway is currently discoverable. + Missing, +} + +/// The running local-sidecar supervisor. +#[derive(Debug)] +pub(crate) struct GatewaySupervisor { + stop: Option>, + thread: Option>, +} + +impl GatewaySupervisor { + /// Stops supervision without waiting for a probe or backoff interval. + pub(crate) fn shutdown(mut self) { + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + // A synchronous liveness probe or launch race cannot be interrupted. + // Detaching here keeps application shutdown bounded; process exit + // tears down any in-flight supervisor work moments later. + drop(self.thread.take()); + } +} + /// How boot connected the gateway: the fact the quit-everything menu /// item labels and behaves from. #[derive(Debug)] @@ -180,36 +220,49 @@ fn launch_and_attach(run_dir: &Path, exe: &Path) -> anyhow::Result anyhow::Result { + wait_for_launched_file_with(run_dir, timeout, shared_sidecar::resolve) +} + +/// Waits for readiness, then accepts only a connection file that passes the +/// shared process-image, health, and bearer validation. +fn wait_for_launched_file_with( + run_dir: &Path, + timeout: Duration, + mut resolve: Resolve, +) -> anyhow::Result +where + Resolve: FnMut(&Path) -> Result, +{ let deadline = Instant::now() + timeout; - let file = loop { + loop { if let Ok(Some(file)) = ConnectionFile::read(run_dir) { - break file; + let remaining = deadline.saturating_duration_since(Instant::now()); + let url = format!("http://127.0.0.1:{}", file.port); + shared_sidecar::wait_for_health(&url, remaining) + .context("the launched gateway did not answer its health probe")?; + if let Ok(Resolution::Attach(validated)) = resolve(run_dir) { + return Ok(validated); + } } if Instant::now() >= deadline { - anyhow::bail!("the launched gateway wrote no connection file within {timeout:?}"); + anyhow::bail!( + "the launched gateway wrote no validated connection file within {timeout:?}" + ); } std::thread::sleep(POLL_INTERVAL); - }; - let remaining = deadline.saturating_duration_since(Instant::now()); - let url = format!("http://127.0.0.1:{}", file.port); - shared_sidecar::wait_for_health(&url, remaining) - .context("the launched gateway did not answer its health probe")?; - Ok(file) + } } -/// Spawns the gateway detached from the shell's lifetime: the `serve` -/// subcommand (boot discovery self-provisions the config on first run), -/// silent stdio, and on Windows broken out of any job object with no +/// Spawns the gateway detached from the shell's lifetime: the bare +/// invocation serves (boot discovery self-provisions the config on first +/// run), silent stdio, and on Windows broken out of any job object with no /// console of its own, so the gateway survives the shell's exit. fn spawn_detached(exe: &Path) -> std::io::Result<()> { let mut command = std::process::Command::new(exe); command - .arg("serve") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()); @@ -248,6 +301,132 @@ fn spawn_detached(exe: &Path) -> std::io::Result<()> { Ok(()) } +/// Starts runtime supervision only for a connection-file sidecar. +/// +/// Explicitly configured LAN endpoints return `None`: their address is fixed, +/// and this process neither probes them for replacement nor launches anything. +pub(crate) fn supervise( + attachment: &GatewayAttachment, + updater: workshop_server::GatewayUpdater, + slot: crate::GatewaySlot, +) -> anyhow::Result> { + let Some(initial) = attachment.sidecar_file().cloned() else { + return Ok(None); + }; + let run_dir = shared_sidecar::default_run_dir().context("locate the sidecar run directory")?; + let exe_dir = std::env::current_exe() + .context("locate the executable")? + .parent() + .map(Path::to_path_buf) + .context("the executable has no parent directory")?; + let sibling = sibling_gateway(&exe_dir); + let (stop_tx, stop_rx) = mpsc::channel(); + let thread = std::thread::Builder::new() + .name("gateway-supervisor".to_owned()) + .spawn(move || { + run_supervision( + initial, + |_| match shared_sidecar::resolve(&run_dir) { + Ok(Resolution::Attach(file)) => SupervisionProbe::Replacement(file), + Ok(_) => SupervisionProbe::Missing, + Err(error) => { + eprintln!("could not re-resolve the local gateway: {error}"); + SupervisionProbe::Missing + } + }, + || { + let exe = sibling.as_deref().context( + "the local gateway disappeared and no sibling gateway executable is installed", + )?; + launch_and_attach(&run_dir, exe) + }, + |file| { + let validated = ValidatedConnection::validate(file.clone()) + .context("validate the replacement gateway identity")?; + updater + .replace_sidecar(&validated) + .context("publish the replacement gateway endpoint")?; + *slot + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(file.clone()); + Ok(()) + }, + |delay| match stop_rx.recv_timeout(delay) { + Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => true, + Err(mpsc::RecvTimeoutError::Timeout) => false, + }, + ); + }) + .context("spawn the gateway supervisor")?; + Ok(Some(GatewaySupervisor { + stop: Some(stop_tx), + thread: Some(thread), + })) +} + +/// Runs the supervision state machine with I/O injected for deterministic +/// liveness and recovery tests. +fn run_supervision( + mut current: ConnectionFile, + mut probe: Probe, + mut recover: Recover, + mut publish: Publish, + mut wait: Wait, +) where + Probe: FnMut(&ConnectionFile) -> SupervisionProbe, + Recover: FnMut() -> Result, + Publish: FnMut(&ConnectionFile) -> Result<(), Error>, + Wait: FnMut(Duration) -> bool, + Error: std::fmt::Display, +{ + let mut retry_delay = SUPERVISION_BASE_DELAY; + loop { + match probe(¤t) { + SupervisionProbe::Replacement(file) if same_gateway_identity(&file, ¤t) => { + retry_delay = SUPERVISION_BASE_DELAY; + if wait(SUPERVISION_INTERVAL) { + return; + } + continue; + } + SupervisionProbe::Replacement(file) => match publish(&file) { + Ok(()) => { + current = file; + retry_delay = SUPERVISION_BASE_DELAY; + continue; + } + Err(error) => { + eprintln!("could not publish a replacement local gateway: {error}"); + } + }, + SupervisionProbe::Missing => match recover() { + Ok(file) => match publish(&file) { + Ok(()) => { + current = file; + retry_delay = SUPERVISION_BASE_DELAY; + continue; + } + Err(error) => { + eprintln!("could not publish a replacement local gateway: {error}"); + } + }, + Err(error) => { + eprintln!("could not recover the local gateway: {error}"); + } + }, + } + if wait(retry_delay) { + return; + } + retry_delay = retry_delay.saturating_mul(2).min(SUPERVISION_MAX_DELAY); + } +} + +/// Whether two validated connection files describe the same Gateway boot. +fn same_gateway_identity(left: &ConnectionFile, right: &ConnectionFile) -> bool { + left.pid == right.pid && left.epoch == right.epoch && left.started_at == right.started_at +} + #[cfg(test)] mod tests { use super::*; @@ -314,19 +493,23 @@ mod tests { let port = listener.local_addr().expect("fixture address").port(); std::thread::spawn(move || { while let Ok((mut stream, _)) = listener.accept() { - let mut buffer = [0u8; 1024]; - let Ok(read) = stream.read(&mut buffer) else { - continue; - }; - let request = String::from_utf8_lossy(&buffer[..read]); - let response = if request.starts_with("GET /health ") - || request.contains(&format!("Authorization: Bearer {expected_key}\r\n")) - { - &b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"[..] - } else { - &b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"[..] - }; - let _ = stream.write_all(response); + for _ in 0..2 { + let mut buffer = [0u8; 1024]; + let Ok(read) = stream.read(&mut buffer) else { + break; + }; + let request = String::from_utf8_lossy(&buffer[..read]); + let accepted = request.starts_with("GET /health ") + || request.contains(&format!("Authorization: Bearer {expected_key}\r\n")); + let response = if accepted { + &b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"[..] + } else { + &b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"[..] + }; + if stream.write_all(response).is_err() { + break; + } + } } }); port @@ -480,8 +663,9 @@ mod tests { .expect("the launched gateway writes"); }); - let waited = wait_for_launched_file(run.path(), Duration::from_secs(5)) - .expect("the file lands and answers"); + let waited = + wait_for_launched_file_with(run.path(), Duration::from_secs(5), probe_own_image) + .expect("the validated file lands and answers"); assert_eq!(waited, file); writer.join().expect("the writer thread ran"); } @@ -489,14 +673,31 @@ mod tests { #[test] fn the_launch_wait_times_out_when_no_file_appears() { let run = tempfile::TempDir::new().expect("tempdir"); - let error = wait_for_launched_file(run.path(), Duration::from_millis(150)) - .expect_err("a gateway that never writes must not hang boot"); + let error = + wait_for_launched_file_with(run.path(), Duration::from_millis(150), probe_own_image) + .expect_err("a gateway that never writes must not hang boot"); assert!( - error.to_string().contains("no connection file"), + error.to_string().contains("no validated connection file"), "the error names the missing file: {error}" ); } + #[test] + fn the_launch_wait_rejects_a_key_the_live_process_does_not_accept() { + let run = tempfile::TempDir::new().expect("tempdir"); + let file = live_file(fixture_gateway("accepted-key"), "rejected-key"); + file.write_to(run.path()).expect("write"); + + let error = + wait_for_launched_file_with(run.path(), Duration::from_millis(150), probe_own_image) + .expect_err("an unaccepted connection-file key must not publish"); + + assert!( + error.to_string().contains("no validated connection file"), + "the error names the validation failure without exposing the key: {error}" + ); + } + #[test] fn an_explicit_config_attachment_holds_no_file_for_the_shutdown_post() { let file = live_file(1, "k"); @@ -526,4 +727,136 @@ mod tests { "the error names the explicit-config remedy: {message}" ); } + + #[test] + fn supervision_lives_past_sixty_seconds_then_propagates_a_configured_key_edit_atomically() { + use std::cell::{Cell, RefCell}; + + let original = live_file(54_375, "old-key"); + let replacement = ConnectionFile { + api_key: "new-key".to_owned(), + pid: original.pid + 1, + epoch: original.epoch + 1, + started_at: "2026-09-03T12:00:01Z".to_owned(), + ..original.clone() + }; + let elapsed = Cell::new(Duration::ZERO); + let recoveries = Cell::new(0_u8); + let published = RefCell::new(Vec::new()); + + run_supervision( + original.clone(), + |current| { + if !published.borrow().is_empty() || elapsed.get() <= Duration::from_secs(65) { + SupervisionProbe::Replacement(current.clone()) + } else { + SupervisionProbe::Missing + } + }, + || { + recoveries.set(recoveries.get() + 1); + if recoveries.get() < 3 { + anyhow::bail!("injected launch failure"); + } + Ok(replacement.clone()) + }, + |file| { + published.borrow_mut().push(file.clone()); + Ok(()) + }, + |delay| { + assert!( + delay <= SUPERVISION_MAX_DELAY, + "every supervision wait is capped: {delay:?}" + ); + elapsed.set(elapsed.get() + delay); + !published.borrow().is_empty() + }, + ); + + assert!( + elapsed.get() > Duration::from_secs(60), + "the supervisor remains live beyond one minute" + ); + assert_eq!(recoveries.get(), 3, "failed launches retry under backoff"); + assert_eq!( + published.borrow().as_slice(), + [replacement], + "one successful relaunch publishes its exact connection-file pair" + ); + assert_eq!( + published.borrow()[0].port, + original.port, + "an OS-assigned port may be reused" + ); + assert_ne!( + published.borrow()[0].api_key, + original.api_key, + "a configured key edit propagates with the replacement identity" + ); + } + + #[test] + fn a_new_pid_replacement_publishes_even_when_port_and_key_are_unchanged() { + use std::cell::RefCell; + + let original = live_file(54_375, "stable-key"); + let replacement = ConnectionFile { + pid: original.pid + 1, + epoch: original.epoch + 1, + started_at: "2026-09-03T12:00:01Z".to_owned(), + ..original.clone() + }; + let published = RefCell::new(Vec::new()); + + run_supervision( + original.clone(), + |current| { + if published.borrow().is_empty() { + SupervisionProbe::Replacement(replacement.clone()) + } else { + SupervisionProbe::Replacement(current.clone()) + } + }, + || -> anyhow::Result { + panic!("a validated replacement does not need a relaunch") + }, + |file| { + published.borrow_mut().push(file.clone()); + Ok(()) + }, + |_| !published.borrow().is_empty(), + ); + + assert_eq!( + published.borrow().as_slice(), + [replacement], + "new process identity publishes the exact stable endpoint and credential pair" + ); + assert_eq!(published.borrow()[0].port, original.port); + assert_eq!(published.borrow()[0].api_key, original.api_key); + } + + #[test] + fn pid_or_boot_identity_distinguishes_replacement_from_endpoint_changes() { + let original = live_file(54_375, "stable-key"); + let new_pid = ConnectionFile { + pid: original.pid + 1, + ..original.clone() + }; + let new_boot = ConnectionFile { + epoch: original.epoch + 1, + started_at: "2026-09-03T12:00:01Z".to_owned(), + ..original.clone() + }; + let endpoint_only = ConnectionFile { + port: 54_379, + api_key: "edited-without-restart".to_owned(), + ..original.clone() + }; + + assert!(!same_gateway_identity(&original, &new_pid)); + assert!(!same_gateway_identity(&original, &new_boot)); + assert!(same_gateway_identity(&original, &endpoint_only)); + } } diff --git a/crates/workshop/src/main.rs b/crates/workshop/src/main.rs index 8b08024e..0b27447c 100644 --- a/crates/workshop/src/main.rs +++ b/crates/workshop/src/main.rs @@ -35,7 +35,7 @@ mod navigation; use std::ffi::OsStr; use std::process::ExitCode; -use std::sync::{Mutex, PoisonError}; +use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; use anyhow::Context as _; @@ -57,7 +57,10 @@ type ServerSlot = Mutex>; /// connection file, for the quit-everything menu item's `/shutdown` post. /// `None` when the gateway came from explicit config (a LAN gateway the /// shell never stops). -type GatewaySlot = Mutex>; +type GatewaySlot = Arc>>; + +/// The managed local-sidecar supervisor, absent for an explicit LAN Gateway. +type GatewaySupervisorSlot = Mutex>; /// The permission set the workshop page holds. The grant itself is built /// in setup with the exact bound port: the OS assigns the port at boot, so @@ -155,6 +158,14 @@ fn run() -> anyhow::Result<()> { .context("build the desktop application")?; app.run(|handle, event| { if let tauri::RunEvent::Exit = event { + let supervisor = handle.try_state::().and_then(|slot| { + slot.lock() + .unwrap_or_else(PoisonError::into_inner) + .take() + }); + if let Some(supervisor) = supervisor { + supervisor.shutdown(); + } let server = handle .try_state::() .map(|slot| slot.lock().unwrap_or_else(PoisonError::into_inner).take()); @@ -182,11 +193,12 @@ fn run() -> anyhow::Result<()> { /// and the failure exit code. fn boot_and_open(app: &mut tauri::App) -> Result<(), Box> { match boot() { - Ok((server, url, attachment)) => { + Ok((server, url, attachment, gateway_slot, supervisor)) => { // The capability must exist before the window does: the // authority resolves a window's grants at creation. app.add_capability(window_capability(&url))?; - app.manage(GatewaySlot::new(attachment.sidecar_file().cloned())); + app.manage(gateway_slot); + app.manage(GatewaySupervisorSlot::new(supervisor)); app.manage(ServerSlot::new(Some(server))); menu::install(app, attachment.sidecar_file())?; open_window(app, &url) @@ -204,7 +216,13 @@ fn boot_and_open(app: &mut tauri::App) -> Result<(), Box> /// file first, explicit `workshop.toml` config second - and waits out its /// health probe. A failure after the spawn shuts the server down before /// propagating. -fn boot() -> anyhow::Result<(ServerHandle, url::Url, gateway::GatewayAttachment)> { +fn boot() -> anyhow::Result<( + ServerHandle, + url::Url, + gateway::GatewayAttachment, + GatewaySlot, + Option, +)> { let config = config::load().context("load the workshop configuration")?; let attachment = gateway::ensure_gateway(&config).context("connect to the gateway")?; let server = workshop_server::spawn(config).context("start the in-process workshop server")?; @@ -213,7 +231,21 @@ fn boot() -> anyhow::Result<(ServerHandle, url::Url, gateway::GatewayAttachment) { Ok(()) => { let url = url::Url::parse(server.url()).context("parse the workshop URL")?; - Ok((server, url, attachment)) + let gateway_slot = Arc::new(Mutex::new(attachment.sidecar_file().cloned())); + let supervisor = match gateway::supervise( + &attachment, + server.gateway_updater(), + Arc::clone(&gateway_slot), + ) { + Ok(supervisor) => supervisor, + Err(error) => { + if let Err(shutdown_error) = server.shutdown() { + eprintln!("{shutdown_error:?}"); + } + return Err(error.context("supervise the local gateway")); + } + }; + Ok((server, url, attachment, gateway_slot, supervisor)) } Err(error) => { if let Err(shutdown_error) = server.shutdown() { diff --git a/design/generic-realtime-stt-acceptance.md b/design/generic-realtime-stt-acceptance.md new file mode 100644 index 00000000..c504c514 --- /dev/null +++ b/design/generic-realtime-stt-acceptance.md @@ -0,0 +1,1140 @@ +# Generic Realtime STT installed-package acceptance + +## Status + +Verification round 3 passed the complete automated release suite, rebuilt and silently installed a fresh unsigned package, and passed the installed local-sidecar recovery scenario. Workshop and Gateway stayed live beyond 60 seconds, Workshop survived forced Gateway termination, and a replacement was accepted by changed PID and boot identity while the configured bearer remained stable. The installed replacement passed process-image, health, accepted-bearer, rejected-bearer, Workshop relay, config-proxy, and Realtime checks. Deterministic tests passed for atomic publication to heartbeat, catalog, chat, progress, config proxy, and Realtime, fixed explicit-LAN behavior, same-port and same-key replacement, configured-key edits, and exactly one boundary space between two standalone no-leading dictation takes. + +- Automated release gate: passed +- Installed-process gate: passed +- Post-relaunch recovery gate: passed +- Physical microphone and device-error checks: passed by final operator acceptance +- Installed application: `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-workshop.exe` +- Installed Workshop left open: PID 87536 +- Installed Gateway left open: PID 96452 on port 51984 +- Signing: not tested; release signing remains release-CI-only +- Commit created: no + +## Step 42 verification round 3 at HEAD 5988a6c0 + +### Run boundary + +- Current commit: `5988a6c023f130756a9850e182f3f1a84619dd8c` (`[WIP] Step 42: Run every release gate and repeat acceptance`) +- Shell: Windows PowerShell `5.1.26100.9278` +- Automated suite started: `2026-09-07T15:21:45.1477842Z` +- Automated suite finished: `2026-09-07T15:34:01.5942626Z` +- Initial worktree: clean +- Commit created: no + +### Complete automated release suite + +Every Step 42 command ran independently and exited with code 0: + +- Rust format, all-target all-feature lint, workspace tests, all-feature documentation tests, warning-denied documentation generation, dependency-policy audit, Gateway build, Workshop build, and featureless Gateway check: passed +- Native Whisper equivalence: 5 passed, 0 failed +- Miri engine target: 2 selected tests passed, 0 failed +- Miri protocol target: 11 selected tests passed, 0 failed +- STT architecture script: passed with acyclic crates and exact public-root counts `6, 7, 2, 6` +- STT architecture integration harness: 16 passed, 0 failed +- Workshop UI type and layer gate: passed +- Workshop UI production build: passed +- Workshop UI suite: 69 passed, 0 failed +- Gateway config UI type gate: passed +- Gateway config UI production build: passed +- Gateway config UI suite: 128 passed, 0 failed +- User-guide generation: passed +- mdBook build: passed +- Explicit generated-guide cleanliness diff: passed + +### Deterministic replacement and dictation gates + +- Gateway boot identity tests: 18 passed, 0 failed +- Workshop supervision tests: 16 passed, 0 failed; coverage includes liveness beyond 60 seconds, bounded relaunch, same-port and same-key replacement with a new PID, PID or boot-identity recognition, process-image and bearer validation, configured-key propagation, and unmanaged explicit LAN +- Explicit LAN focused gate: 1 passed, 0 failed +- Atomic Gateway snapshot tests: 2 passed, 0 failed +- Heartbeat and model-catalog replacement gate: 1 passed, 0 failed +- Progress replacement gate: 1 passed, 0 failed +- Config-origin and proxy replacement gate: 1 passed, 0 failed +- Live chat session replacement gate: 1 passed, 0 failed +- Browser Realtime retry through the unchanged Workshop process: 1 passed, 0 failed +- Agent dictation gate: all assertions passed, including two standalone no-leading takes composing as `First test alpha Second test beta` with exactly one boundary space and no duplicate separator when either side already supplies one + +### Generated-document cleanliness + +Guide regeneration, mdBook compilation, and the explicit cleanliness diff passed. SHA-256 identities after regeneration: + +- `guide/src/SUMMARY.md`: `4031AACD9459ED213C3E5D41466993691FD8B2DA07DEC9D090D90E8493F99FFC` +- `guide/src/gateway/index.md`: `09E9807249611001CA6CAF2A1A210BF64E2B843C4C6B6A8C7284068F6E44B2D2` +- `guide/src/workshop/index.md`: `4BC7756A0D6D66807061BD747C72618096B24ACC5031F536F12B4019C53F4226` +- `guide/src/language/index.md`: `41E9E4458BC1ED9F969F0DB7E13C24A3D94A4E88253D88239E6AA3F40F631AFD` +- `guide/src/agent/index.md`: `1C66A4A2EF1AF16AE38668F2D0910124F20520E714610EA8B150C282ECAC623E` +- `guide/promptforge-gateway-guide.md`: `5BF95CD9776A87982E13D9C6E7DA09F7BED2375292E75919F963BF59F497BCDE` +- `guide/promptforge-workshop-guide.md`: `F45DB5FBAE9B56CB4415218CBA2B8D12EEAB39B45DEF10922349C369E2921DF0` +- `guide/promptforge-language-guide.md`: `3CDF6E562EF45AC8873703C81701834650CAC7E8A8459839E96466D03AF16DFA` +- `guide/promptforge-agent-guide.md`: `3B70D4DE22FF4077BC31D9E484BC8672DDF256413795B6BD8708774DB29464F9` + +### Fresh unsigned package and identities + +- Stable locked Gateway release build: passed +- Target-suffixed sidecar staging: passed +- Release, staged, and installed Gateway SHA-256: `C60DFDEBC6E45EEE82AF6952FF81CF1B0A1F0B92ADC8EA1BFE68F82105E8C06B` +- Tauri CLI locked install gate: passed with version 2.11.4 already installed +- Fresh unsigned NSIS package build: passed +- Final installer SHA-256: `5ABFB2436BC18FCD8DD9EF0D0923F414AB47E0D20661978C11A8520305AA21ED` +- Silent install exit code: 0 +- Installed Workshop SHA-256: `6A25AD42F771CE07C1BD7D70365C0436D5CF6CEC91E83D506D97B98C47548C4B` +- Installed Workshop identity: product `PromptForge`, version `0.2.0`, unsigned, expected installed path +- The installed and bundle-stage Workshop images had equal size and matching product identity but different PE hashes after NSIS extraction +- Protected release configuration cleanliness: passed +- Signing: not tested; release signing remains release-CI-only + +The first round 3 package observation successfully built and installed the package, then stopped on a verifier-added Workshop byte-equality assertion. That assertion was not a release requirement and was invalid for the observed NSIS image transformation. The failure remains recorded. The corrected identity gate checks installed path, product name, product version, image size, and unsigned status, while the Gateway sidecar retains byte-for-byte release, staging, and installed equivalence. The corrected package and installation phase was then repeated from the release build and passed. + +### Installed local-sidecar recovery + +- Installed Workshop launched at `2026-09-07T15:37:39.9988414Z`, PID 87536, loopback port 55805 +- Initial installed Gateway: PID 100512, port 55799, installed sibling image, health 200, bearer-authenticated model catalog 200 +- Credential present: yes; value not recorded +- Installed-pair liveness interval: 65.061 seconds +- Workshop and Gateway remained running beyond 60 seconds: yes +- Initial Gateway force-terminated at `2026-09-07T15:38:47.5683322Z` +- Workshop remained open across termination with PID 87536: yes +- Replacement observed after 3.299 seconds +- Replacement Gateway: PID 96452, port 51984, boot identity `2026-09-07T15:38:50.6084321Z` +- Replacement identity changed by PID and boot identity: yes +- Port changed in this observation: yes; deterministic coverage permits OS port reuse +- Configured bearer remained stable: yes; deterministic coverage also proves atomic propagation of a configured edit +- Replacement image path: `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-gateway.exe` +- Replacement health: 200 +- Replacement bearer-authenticated model catalog: 200 +- Replacement invalid-bearer probe: 401 +- Workshop model relay through the replacement: 200 +- Workshop config proxy through the replacement: 200 +- Workshop published replacement origin: `http://127.0.0.1:51984` +- Browser Realtime through unchanged Workshop: open, first frame `session.created` +- No bearer value was written to the evidence + +### Final handoff boundary + +- Installed Workshop PID 87536 remains open +- Installed Gateway PID 96452 remains open on port 51984 +- Final operator acceptance observed at approximately `2026-09-07T15:41Z` +- Operator followed the requested sequential-take, active-take cancellation, delayed-result suppression, microphone-access denial, access restoration, recovery dictation, and chat-turn checks +- Operator verdict: `wow... fucking brilliant :) works great` +- Physical microphone, device-error recovery, and model-turn checks: passed +- All automated, package, installation, identity, supervision, deterministic consumer-recovery, live replacement, and fixed-LAN gates passed + +## Step 42 verification round 2 at HEAD 2d2ee4de + +### Run boundary + +- Current commit: `2d2ee4dede815e9e19a294285fb0fafd8e9c530b` (`[WIP] Step 42: Run every release gate and repeat acceptance`) +- Shell: Windows PowerShell `5.1.26100.9278` +- Automated suite started: `2026-09-07T14:51:49.7303202Z` +- Automated suite finished: `2026-09-07T15:00:50.9390825Z` +- Initial worktree: clean +- Commit created: no + +### Independently executed automated commands + +Every listed command ran as its own process invocation and exited with code 0. + +- `cargo fmt --all --check`: passed +- `cargo clippy --workspace --all-targets --all-features -- -D warnings`: passed +- `cargo test --workspace`: passed; every selected workspace target reported zero failed tests +- `cargo test --workspace --all-features --doc`: passed; every selected documentation target reported zero failed tests +- `$env:RUSTDOCFLAGS='-D warnings'; cargo doc --workspace --no-deps --all-features`: passed +- `cargo deny check`: passed +- `cargo build -p gateway`: passed +- `cargo build -p workshop`: passed +- `cargo check -p gateway --no-default-features`: passed +- `$fixture=(Resolve-Path 'local\stt-fixtures').Path; $env:PATH="$fixture;$env:PATH"; $env:PROMPTFORGE_WHISPER_LIBRARY=(Resolve-Path 'local\stt-fixtures\whisper.dll').Path; cargo test -p gateway-stt-backend-whisper --test native_whisper -- --ignored`: passed, 5 passed and 0 failed +- `cargo +nightly-2026-09-05 miri test -p gateway-stt-engine --features test-fixtures miri_`: passed, 2 selected tests passed and 0 failed +- `cargo +nightly-2026-09-05 miri test -p gateway-stt --features test-fixtures miri_`: passed, 11 selected tests passed and 0 failed +- `node tools/check-stt-architecture.mjs`: passed +- `cargo test -p gateway-stt --test it architecture`: passed, 16 passed and 0 failed +- Workshop UI `npm run typecheck`: passed; `check-layers: ok` +- Workshop UI `npm run build`: passed; emitted `dist/app.js` at 2.5 MiB and `dist/app.css` at 166.0 KiB +- Workshop UI `npm test`: passed, 69 passed and 0 failed, cancelled, or skipped +- Gateway config UI `npm run typecheck`: passed +- Gateway config UI `npm run build`: passed; emitted `dist/app.js` at 291.4 KiB and `dist/app.css` at 37.7 KiB +- Gateway config UI `npm test`: passed, 128 passed and 0 failed, cancelled, or skipped; `check-layers: ok` +- `cargo run -p build-user-guide`: passed +- `mdbook build guide`: passed +- `git diff --exit-code -- guide/src/SUMMARY.md guide/src/gateway/index.md guide/src/workshop/index.md guide/src/language/index.md guide/src/agent/index.md guide/promptforge-gateway-guide.md guide/promptforge-workshop-guide.md guide/promptforge-language-guide.md guide/promptforge-agent-guide.md`: passed + +### Native equivalence and architecture ratchets + +- Native Whisper equivalence: 5 passed, 0 failed, covering the fixed JFK transcript, conditioning, job independence, absent-final classification, and progress terminals +- `gateway-stt`: acyclic, 6 public roots +- `gateway-stt-engine`: acyclic, 7 public roots +- `gateway-stt-backend-whisper`: acyclic, 2 public roots +- `gateway-whisper-ffi`: acyclic, 6 public roots +- The final architecture harness passed all 16 exact-dependency, ceiling, migration, isolation, generation, and seam-removal tests + +### Deterministic sidecar replacement coverage + +The original deterministic gates and the replacement-identity correction gates below all passed: + +- `cargo fmt --all --check`: passed after the correction +- `cargo clippy -p workshop -p workshop-server --all-targets --all-features -- -D warnings`: passed +- `cargo test -p gateway boot::tests::`: 18 passed, 0 failed; covered first-run key generation, existing-config discovery without generation, and refusal to overwrite an existing configured key +- `cargo test -p workshop gateway::tests`: 16 passed, 0 failed; covered more than 60 seconds of supervision, bounded relaunch retries, atomic configured-key edit propagation with an OS-reused port, same-port and same-key publication for a new PID, PID or boot-identity replacement detection, full launch validation, rejected bearer handling without disclosure, and fixed unmanaged explicit-LAN behavior +- `cargo test -p workshop-server gateway_binding::tests`: 2 passed, 0 failed; covered one-snapshot endpoint and credential replacement plus invalid-file rejection before publication +- `cargo test -p workshop-server a_replaced_endpoint_wakes_the_heartbeat_and_refreshes_with_its_new_key`: 1 passed, 0 failed; covered health and model-catalog recovery +- `cargo test -p workshop-server an_endpoint_replacement_moves_the_progress_subscription_immediately`: 1 passed, 0 failed; covered progress recovery +- `cargo test -p workshop-server origin_and_config_proxy_follow_one_replacement_snapshot`: 1 passed, 0 failed; covered atomic origin and config-proxy recovery +- `cargo test -p workshop-server --test it a_live_chat_session_restarts_on_the_replacement_port_and_key`: 1 passed, 0 failed; covered chat recovery +- `cargo test -p workshop-server --test it browser_realtime_retry_reaches_the_new_port_and_key_without_workshop_reload`: 1 passed, 0 failed; covered a browser Realtime retry through the unchanged Workshop process + +### Generated-document cleanliness + +Regeneration and the explicit diff command passed. The post-regeneration SHA-256 identities were: + +- `guide/src/SUMMARY.md`: `4031AACD9459ED213C3E5D41466993691FD8B2DA07DEC9D090D90E8493F99FFC` +- `guide/src/gateway/index.md`: `09E9807249611001CA6CAF2A1A210BF64E2B843C4C6B6A8C7284068F6E44B2D2` +- `guide/src/workshop/index.md`: `4BC7756A0D6D66807061BD747C72618096B24ACC5031F536F12B4019C53F4226` +- `guide/src/language/index.md`: `41E9E4458BC1ED9F969F0DB7E13C24A3D94A4E88253D88239E6AA3F40F631AFD` +- `guide/src/agent/index.md`: `1C66A4A2EF1AF16AE38668F2D0910124F20520E714610EA8B150C282ECAC623E` +- `guide/promptforge-gateway-guide.md`: `5BF95CD9776A87982E13D9C6E7DA09F7BED2375292E75919F963BF59F497BCDE` +- `guide/promptforge-workshop-guide.md`: `F45DB5FBAE9B56CB4415218CBA2B8D12EEAB39B45DEF10922349C369E2921DF0` +- `guide/promptforge-language-guide.md`: `3CDF6E562EF45AC8873703C81701834650CAC7E8A8459839E96466D03AF16DFA` +- `guide/promptforge-agent-guide.md`: `3B70D4DE22FF4077BC31D9E484BC8672DDF256413795B6BD8708774DB29464F9` + +### Fresh unsigned NSIS package + +Packaging began only after the automated suite and deterministic replacement gates passed. + +- Command: `$env:RUSTUP_TOOLCHAIN='stable'; cargo build --release --locked -p gateway` + - Result: passed + - Release Gateway: `C:\Users\Vinnie\cursor\promptforge\target\release\promptforge-gateway.exe` + - Last modified: `2026-09-07T13:53:06.4583106Z` + - Size: 13,405,696 bytes + - SHA-256: `C60DFDEBC6E45EEE82AF6952FF81CF1B0A1F0B92ADC8EA1BFE68F82105E8C06B` +- Command: `$triple='x86_64-pc-windows-msvc'; New-Item -ItemType Directory -Path 'crates\workshop\binaries' -Force | Out-Null; Copy-Item 'target\release\promptforge-gateway.exe' "crates\workshop\binaries\promptforge-gateway-$triple.exe" -Force` + - Result: passed + - Staged Gateway size: 13,405,696 bytes + - Staged Gateway SHA-256: `C60DFDEBC6E45EEE82AF6952FF81CF1B0A1F0B92ADC8EA1BFE68F82105E8C06B` + - Release and staged hashes matched +- Command: `$env:RUSTUP_TOOLCHAIN='stable'; cargo install tauri-cli --locked` + - Result: passed + - Installed Tauri CLI version remained `2.11.4` +- Initial package invocation at `2026-09-07T15:02:37.7643469Z`: + - Result: failed before compilation with exit code 2 because PowerShell stripped the inline JSON key quotes + - Preserved failure: Tauri reported `{bundle:{createUpdaterArtifacts:false}}` was invalid JSON +- Corrected exact PowerShell 5.1 command: `$env:RUSTUP_TOOLCHAIN='stable'; cargo --% tauri build --bundles nsis --config {\"bundle\":{\"createUpdaterArtifacts\":false}}` + - Result: passed, exit code 0 + - Build started: `2026-09-07T15:02:52.6758669Z` + - Build finished: `2026-09-07T15:04:11.9473657Z` + - Protected release configuration remained unchanged + - `bundle.createUpdaterArtifacts=false` was supplied only through the command line +- Fresh installer: + - Path: `C:\Users\Vinnie\cursor\promptforge\target\release\bundle\nsis\PromptForge_0.2.0_x64-setup.exe` + - Created: `2026-09-07T15:03:51.3220507Z` + - Last modified: `2026-09-07T15:04:11.8150990Z` + - Size: 12,034,299 bytes + - SHA-256: `F4BEBF02DBDD6E2B61C2769674EBC8FDCBA9A1A58E00E631C4FA776D7C8F1D0F` + - Previous installer SHA-256: `194D2D6D86C6E552E12E1E7D96E5469914FE11E588B123E6313833E0C49A9F78` + - Freshness: creation and modification followed the successful package start, and the SHA-256 changed + - Signing: not tested; release signing remains release-CI-only + +### Installation and installed identities + +- Installed PromptForge processes observed before installation: 0 +- Silent installer start: `2026-09-07T15:04:12.2545905Z` +- Silent installer finish: `2026-09-07T15:04:15.6907930Z` +- Installer exit code: 0 +- Installed Workshop: + - Path: `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-workshop.exe` + - Last modified: `2026-09-07T15:03:50Z` + - Size: 24,229,376 bytes + - SHA-256: `1238646F70A7B84CBEBD12523925022C3215315B51F487258C484F44C6AAEE86` +- Installed Gateway: + - Path: `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-gateway.exe` + - Last modified: `2026-09-07T13:53:06Z` + - Size: 13,405,696 bytes + - SHA-256: `C60DFDEBC6E45EEE82AF6952FF81CF1B0A1F0B92ADC8EA1BFE68F82105E8C06B` + - Installed, staged, and release Gateway hashes matched exactly + +### Installed local-sidecar observation + +- Installed Workshop launched: `2026-09-07T15:04:16.1579831Z` +- Workshop PID: 55388 +- Workshop path: `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-workshop.exe` +- Workshop loopback port: 57155 +- Initial installed Gateway PID: 92432 +- Initial installed Gateway port: 57150 +- Initial installed Gateway path: `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-gateway.exe` +- Initial Gateway key present: yes; the value was not recorded +- Initial Gateway health status: 200 +- Initial Gateway model-catalog status: 200 +- Installed-pair liveness interval: 65.060 seconds +- Workshop remained running beyond 60 seconds: yes +- Gateway remained running beyond 60 seconds: yes + +The initial Gateway was force-terminated at `2026-09-07T15:05:23.2482189Z` while Workshop PID 55388 remained running. Gateway logging and the live connection file showed an installed sibling replacement starting at `2026-09-07T15:05:26.7613168Z`, with PID 35008 and port 60892. The first observer timed out because it incorrectly required PID, port, and key all to change. Replacement requires a new PID or boot identity, while an OS-assigned port may be reused and an unchanged configuration preserves its long-term credential. + +A second direct observation repeated the forced termination: + +- Gateway before termination: PID 35008 on port 60892 +- Forced termination: `2026-09-07T15:08:24.5934571Z` +- Workshop PID 55388 stayed running: yes +- Replacement Gateway started: `2026-09-07T15:08:27.1643234Z` +- Replacement observed after approximately 2.571 seconds +- Replacement Gateway PID: 59984 +- Replacement Gateway port: 61013 +- Replacement Gateway path: `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-gateway.exe` +- PID changed: yes +- Port changed: yes +- Bearer key remained present: yes +- Bearer key changed: no, as expected for the unchanged Gateway configuration +- Bearer key value: not recorded +- Actual installed health, model-catalog, chat, progress, config-proxy, and Realtime recovery probes after relaunch: not run because the observer applied the false changed-key precondition +- Release verdict: incomplete; the replacement process identity passed, but installed recovery and remaining physical acceptance still require observation + +### Final handoff boundary + +- Installed Workshop PID 55388 remained open at `2026-09-07T15:10Z` +- Installed Gateway PID 59984 remained open on port 61013 at `2026-09-07T15:10Z` +- No microphone, second-take, Clear, cancellation, permission-denial, unavailable-device, or model-turn scenario was physically performed in this verification round +- Signing was not tested; release signing remains release-CI-only + +## Final topology and documentation evidence + +This section records the Step 40 architecture result. It does not replace or extend the installed-microphone verdict above. + +### Debt before and after + +- Temporary workspace dependency exceptions: 1 before, 0 after +- Migration-target exceptions: 6 before, 0 after +- Forbidden `gateway-stt -> workshop-server` edges: 1 before, 0 after +- STT source modules above 500 physical lines: 3 before, 0 after +- Largest STT source module: 712 lines before, 481 after +- Effective public-root policy: allowances `9, 7, 2, 6` before; exact counts `6, 7, 2, 6` after +- STT production-library module cycles: 0 after +- Legacy `/stt`, `/stt/capability`, Workshop status/header, and Workshop STT dependency exceptions: 0 after + +The engine's 667-line scripted fixture was split into a 362-line fixture and a 304-line test module without changing its 22 unit, 6 contract, 8 startup-cleanup, or documentation test results. + +### Final gates + +- `node --test tools/check-stt-architecture.test.mjs`: passed, 13 tests +- `node tools/check-stt-architecture.mjs`: passed; all four STT crates acyclic with exact public roots `6, 7, 2, 6` +- `cargo test -p gateway-stt --test it architecture`: passed, 15 tests +- `cargo fmt --all --check`: passed +- `cargo run -p build-user-guide`: passed; all nine generated artifacts had identical SHA-256 values on the clean second run +- `$env:RUSTUP_TOOLCHAIN='stable'; cargo install mdbook --version 0.4.44 --locked`: passed +- `mdbook build guide`: passed + +### Final documentation and rules audit + +- Added the final architecture design covering ownership, exact dependencies, public counts, Realtime wire policy, bounds, profile replacement, Workshop relay behavior, CI gates, and debt results. +- Updated Gateway, config, Workshop server, and source-guide documentation to remove the retired custom routes and describe `/v1/realtime`. +- Regenerated every guide index and all four single-file exports through `build-user-guide`. +- Corrected the root build prerequisite because a Gateway-only build no longer includes Workshop UI tooling. +- Corrected Workshop's error rule because Workshop no longer provisions STT. +- Audited `gateway-stt`, `gateway-stt-engine`, `gateway-stt-backend-whisper`, `gateway`, and `shared-loopback` rules; their final constraints remain concrete and correct, so they were unchanged. + +## Latest installed preparation from HEAD 2d1ecca8 + +### Source and prior process boundary + +- Current HEAD: `2d1ecca839634034d5b70901229d9012e74a18a0` +- Current commit: `2d1ecca8` (`Reconcile explicitly skipped final ranges`) +- Installed Workshop or Gateway processes observed before rebuild: 0 +- Installed Workshop or Gateway processes stopped: 0 +- Installed Workshop or Gateway processes remaining before rebuild: 0 +- Installed Workshop or Gateway processes observed immediately before installation: 0 + +### Release Gateway + +- Command: `$env:RUSTUP_TOOLCHAIN='stable'; cargo build --release --locked -p gateway` +- Result: passed +- Summary: release profile finished in 21.80 seconds with 9 `gateway-stt` warnings +- Build started: `2026-09-07T11:38:03.1101939Z` +- Build finished: `2026-09-07T11:38:25.0377360Z` +- Artifact: `target/release/promptforge-gateway.exe` +- Last modified: `2026-09-07T11:38:24.5038009Z` +- Size: 14,536,192 bytes +- SHA-256: `2745D151F7ADD0368308D2029976A11D4BAF38ECBA262C0AC27F57E83D67F74B` + +### Target-suffixed sidecar + +- Command: `$triple='x86_64-pc-windows-msvc'; New-Item -ItemType Directory -Path 'crates\workshop\binaries' -Force | Out-Null; Copy-Item 'target\release\promptforge-gateway.exe' "crates\workshop\binaries\promptforge-gateway-$triple.exe" -Force` +- Artifact: `crates/workshop/binaries/promptforge-gateway-x86_64-pc-windows-msvc.exe` +- Last modified: `2026-09-07T11:38:24.5038009Z` +- Size: 14,536,192 bytes +- SHA-256: `2745D151F7ADD0368308D2029976A11D4BAF38ECBA262C0AC27F57E83D67F74B` +- Verification: source and staged SHA-256 hashes matched at `2026-09-07T11:38:39.6850080Z` + +### Packaging tool + +- Command: `$env:RUSTUP_TOOLCHAIN='stable'; cargo install tauri-cli --locked` +- Result: passed +- Installed version: `tauri-cli 2.11.4` +- Detail: Cargo reported that the same version was already installed +- Verified: `2026-09-07T11:38:39.6299436Z` + +### Fresh unsigned local NSIS package + +- Exact successful PowerShell command: `cargo --% tauri build --bundles nsis --config {\"bundle\":{\"createUpdaterArtifacts\":false}}` +- Working directory: `crates/workshop` +- Result: passed +- Build started: `2026-09-07T11:38:45.9576171Z` +- Build finished: `2026-09-07T11:39:54.3354945Z` +- Workshop release profile finished in 47.06 seconds +- Installer: `target/release/bundle/nsis/PromptForge_0.2.0_x64-setup.exe` +- Installer created: `2026-09-07T11:39:35.7152137Z` +- Installer last modified: `2026-09-07T11:39:54.2055172Z` +- Installer size: 12,393,556 bytes +- Installer SHA-256: `CE476DE44A6F7E0897765ED45AA6E988702826FC9F4B7083A155DBE90E90F028` +- Previous installer SHA-256: `DD2A21369B0834084F26D22ADAE92896431574506C607749F12FFC546ACB78D7` +- Freshness proof: the installer creation and modification timestamps follow the successful build start, and its hash differs from the previous installer +- Override scope: `bundle.createUpdaterArtifacts=false` was supplied only through the Tauri command line +- Protected release configuration: `crates/workshop/tauri.conf.json` and `.github/workflows/release-workshop.yml` have no diff +- Signing: not tested + +The adjacent `PromptForge_0.2.0_x64-setup.exe.sig` remains stale from `2026-09-06T02:42:54.1030502Z` and is excluded from this build's evidence. + +### Silent installation and installed identities + +- Install result: passed +- Installer exit code: 0 +- Install started: `2026-09-07T11:40:09.9664232Z` +- Install finished: `2026-09-07T11:40:13.3574707Z` +- Workshop path: `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-workshop.exe` +- Workshop file version: `0.2.0` +- Workshop product version: `0.2.0` +- Workshop last modified: `2026-09-07T11:39:34Z` +- Workshop size: 24,290,816 bytes +- Workshop SHA-256: `36AA10231DA4177C859494B2FD4A116C68EEA12B3BA7C704AB788D16AB6F532C` +- Build-tree Workshop size: 24,290,816 bytes +- Build-tree Workshop SHA-256: `41A5252E66179E48B76C9CED552B730EFF644F18788F87D8D4C6539EBEF1A867` +- Workshop comparison: both identities are recorded without claiming equality because the Tauri log records NSIS bundle-information patching during packaging +- Gateway sibling path: `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-gateway.exe` +- Gateway sibling last modified: `2026-09-07T11:38:24Z` +- Gateway sibling size: 14,536,192 bytes +- Gateway sibling SHA-256: `2745D151F7ADD0368308D2029976A11D4BAF38ECBA262C0AC27F57E83D67F74B` +- Gateway verification: installed, staged, and release SHA-256 hashes match +- Identity verification observed: `2026-09-07T11:40:29.6211886Z` + +### Installed application launch + +- Launched: `2026-09-07T11:40:35.8875527Z` +- Readiness-window observation: `2026-09-07T11:41:06.4335573Z` +- Workshop process ID: 68872 +- Gateway process ID: 91128 +- Both process paths resolve under `C:\Users\Vinnie\AppData\Local\PromptForge` +- Both processes remained running at `2026-09-07T11:41:37.3425936Z` +- No physical microphone, model-menu, or model-turn checklist item was observed during automated preparation + +## Prior installed preparation after whole-window scheduler repair + +### Source and prior process boundary + +- Current HEAD: `006ba06d945ec0bfacbb0a0270f65d2706eb20db` +- Current commit: `006ba06d` (`Schedule and rebase whole-window hypotheses`) +- Installed Workshop or Gateway processes observed before rebuild: 0 +- Installed Workshop or Gateway processes stopped: 0 +- Installed Workshop or Gateway processes remaining before rebuild: 0 +- Process boundary observed: `2026-09-07T10:42:20.0755391Z` + +### Release Gateway + +- Command: `$env:RUSTUP_TOOLCHAIN='stable'; cargo build --release --locked -p gateway` +- Result: passed +- Summary: release profile finished in 21.49 seconds with 9 `gateway-stt` warnings +- Build started: `2026-09-07T10:42:19.6689474Z` +- Build finished: `2026-09-07T10:42:41.2751764Z` +- Artifact: `target/release/promptforge-gateway.exe` +- Last modified: `2026-09-07T10:42:40.7268324Z` +- Size: 14,524,928 bytes +- SHA-256: `E3D4DD8694423F69DBE1A828BD2915C04E1E6963E2510867F6FC4A1E170C0FA4` + +### Target-suffixed sidecar + +- Command: `$triple='x86_64-pc-windows-msvc'; New-Item -ItemType Directory -Path 'crates\workshop\binaries' -Force | Out-Null; Copy-Item 'target\release\promptforge-gateway.exe' "crates\workshop\binaries\promptforge-gateway-$triple.exe" -Force` +- Artifact: `crates/workshop/binaries/promptforge-gateway-x86_64-pc-windows-msvc.exe` +- Last modified: `2026-09-07T10:42:40.7268324Z` +- Size: 14,524,928 bytes +- SHA-256: `E3D4DD8694423F69DBE1A828BD2915C04E1E6963E2510867F6FC4A1E170C0FA4` +- Verification: source and staged SHA-256 hashes matched at `2026-09-07T10:42:47.8474773Z` + +### Packaging tool + +- Command: `$env:RUSTUP_TOOLCHAIN='stable'; cargo install tauri-cli --locked` +- Result: passed +- Installed version: `tauri-cli 2.11.4` +- Detail: Cargo reported that the same version was already installed +- Verified: `2026-09-07T10:42:47.7978686Z` + +### Fresh unsigned local NSIS package + +- Exact successful PowerShell command: `cargo --% tauri build --bundles nsis --config {\"bundle\":{\"createUpdaterArtifacts\":false}}` +- Working directory: `crates/workshop` +- Result: passed +- Build started: `2026-09-07T10:42:55.4726299Z` +- Build finished: `2026-09-07T10:44:25.3444665Z` +- Workshop release profile finished in 59.98 seconds +- Installer: `target/release/bundle/nsis/PromptForge_0.2.0_x64-setup.exe` +- Installer created: `2026-09-07T10:43:58.3597272Z` +- Installer last modified: `2026-09-07T10:44:16.9471184Z` +- Installer size: 12,507,285 bytes +- Installer SHA-256: `DD2A21369B0834084F26D22ADAE92896431574506C607749F12FFC546ACB78D7` +- Previous installer SHA-256: `CD114A03A98F5E9F3354DC889998742E01EB3C221DB0CA61F0AA835DBDED885D` +- Freshness proof: the installer creation and modification timestamps follow the successful build start, and its hash differs from the previous installer +- Override scope: `bundle.createUpdaterArtifacts=false` was supplied only through the Tauri command line +- Protected release configuration: `crates/workshop/tauri.conf.json` and `.github/workflows/release-workshop.yml` have no diff +- Signing: not tested + +The adjacent `PromptForge_0.2.0_x64-setup.exe.sig` remains stale from `2026-09-06T02:42:54.1030502Z` and is excluded from this build's evidence. + +### Silent installation and installed identities + +- Install result: passed +- Installer exit code: 0 +- Install started: `2026-09-07T10:44:29.7572459Z` +- Install finished: `2026-09-07T10:44:33.1346052Z` +- Workshop path: `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-workshop.exe` +- Workshop version output: `promptforge-workshop 0.2.0` +- Workshop file version: `0.2.0` +- Workshop product version: `0.2.0` +- Workshop last modified: `2026-09-07T10:43:56Z` +- Workshop size: 24,881,664 bytes +- Workshop SHA-256: `3D95568DECE542DC0D56404FBFFB49567EAACED58E1A8CB11B5836572D33B8B6` +- Build-tree Workshop size: 24,881,664 bytes +- Build-tree Workshop SHA-256: `6779FC9020AC6CF48299E16F77EF1EBCE3533056185295F9DF13882E20FA618B` +- Workshop comparison: both identities are recorded without claiming equality because the Tauri log records NSIS bundle-information patching during packaging +- Gateway sibling path: `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-gateway.exe` +- Gateway sibling last modified: `2026-09-07T10:42:40Z` +- Gateway sibling size: 14,524,928 bytes +- Gateway sibling SHA-256: `E3D4DD8694423F69DBE1A828BD2915C04E1E6963E2510867F6FC4A1E170C0FA4` +- Gateway verification: installed, staged, and release SHA-256 hashes match +- Identity verification observed: `2026-09-07T10:44:43.0748367Z` + +### Installed application launch + +- Launched: `2026-09-07T10:44:51.0854084Z` +- Readiness-window observation: `2026-09-07T10:45:11.1809954Z` +- Workshop process ID: 95492 +- Gateway process ID: 77192 +- Both process paths resolve under `C:\Users\Vinnie\AppData\Local\PromptForge` +- Both processes remained running at `2026-09-07T10:45:38.3200361Z` +- No physical microphone, model-menu, or model-turn checklist item was observed during automated preparation + +## Prior installed preparation after Steps 34 and 35 + +### Source and prior process boundary + +- Current HEAD: `e7216d92c58f50d0c9b967bf4123e877b922cf47` +- Step 34 commit: `fb4e0bfe` (`Converge chat sessions with live catalogs`) +- Step 35 commit: `e7216d92` (`Partition live hypotheses into disjoint fields`) +- Installed Workshop or Gateway processes observed before rebuild: 0 +- Installed Workshop or Gateway processes stopped: 0 + +### Release Gateway + +- Command: `$env:RUSTUP_TOOLCHAIN='stable'; cargo build --release --locked -p gateway` +- Result: passed +- Summary: release profile finished in 26.57 seconds with 9 `gateway-stt` warnings +- Build started: `2026-09-07T09:39:44.2506384Z` +- Build finished: `2026-09-07T09:40:10.9436429Z` +- Artifact: `target/release/promptforge-gateway.exe` +- Last modified: `2026-09-07T09:40:10.4052397Z` +- Size: 14,477,824 bytes +- SHA-256: `D79453C2D91A6AF921C93C861624C8CCA4AC31497E1AF19AA38E458849E119DC` + +### Target-suffixed sidecar + +- Command: `$triple='x86_64-pc-windows-msvc'; New-Item -ItemType Directory -Path 'crates\workshop\binaries' -Force | Out-Null; Copy-Item 'target\release\promptforge-gateway.exe' "crates\workshop\binaries\promptforge-gateway-$triple.exe" -Force` +- Artifact: `crates/workshop/binaries/promptforge-gateway-x86_64-pc-windows-msvc.exe` +- Size: 14,477,824 bytes +- SHA-256: `D79453C2D91A6AF921C93C861624C8CCA4AC31497E1AF19AA38E458849E119DC` +- Verification: source and staged SHA-256 hashes matched at `2026-09-07T09:40:21.0845907Z` + +### Packaging tool + +- Command: `$env:RUSTUP_TOOLCHAIN='stable'; cargo install tauri-cli --locked` +- Result: passed +- Installed version: `tauri-cli 2.11.4` +- Detail: Cargo reported that the same version was already installed +- Verified: `2026-09-07T09:40:28.3091432Z` + +### Fresh unsigned local NSIS package + +- Exact successful PowerShell command: `cargo --% tauri build --bundles nsis --config {\"bundle\":{\"createUpdaterArtifacts\":false}}` +- Working directory: `crates/workshop` +- Result: passed +- Build started: `2026-09-07T09:40:48.847Z` +- Build finished: `2026-09-07T09:42:15.042Z` +- Workshop release profile finished in 59.28 seconds +- Installer: `target/release/bundle/nsis/PromptForge_0.2.0_x64-setup.exe` +- Installer created: `2026-09-07T09:41:51.6524557Z` +- Installer last modified: `2026-09-07T09:42:13.4020073Z` +- Installer size: 12,381,612 bytes +- Installer SHA-256: `CD114A03A98F5E9F3354DC889998742E01EB3C221DB0CA61F0AA835DBDED885D` +- Previous installer SHA-256: `CFBB5CBB539BE6B77FAB17BC9030E76CBA3D55B1DB21E84D5BD95CAF08E52606` +- Freshness proof: the installer creation and modification timestamps follow the successful build start, and its hash differs from the previous installer +- Override scope: `bundle.createUpdaterArtifacts=false` was supplied only through the Tauri command line +- Protected release configuration: `crates/workshop/tauri.conf.json` and `.github/workflows/release-workshop.yml` have no diff +- Signing: not tested + +The adjacent `PromptForge_0.2.0_x64-setup.exe.sig` remains stale from `2026-09-06T02:42:54.1030502Z` and is excluded from this build's evidence. + +### Silent installation and installed identities + +- Install result: passed +- Installer exit code: 0 +- Install started: `2026-09-07T09:42:28.9881969Z` +- Install finished: `2026-09-07T09:42:32.3954431Z` +- Workshop path: `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-workshop.exe` +- Workshop file version: `0.2.0` +- Workshop product version: `0.2.0` +- Workshop last modified: `2026-09-07T09:41:50Z` +- Workshop size: 24,290,816 bytes +- Workshop SHA-256: `0BCD250129F2D7B1BE5218326C7FEF8FC93238ACE69CC73AFFF6648FB0F6FE74` +- Build-tree Workshop size: 24,290,816 bytes +- Build-tree Workshop SHA-256: `22897B508E501402B4FF17A207917AD3BF30C573B374E2F4DA671BA8DA160EE8` +- Workshop comparison: both identities are recorded without claiming equality because the Tauri log records NSIS bundle-information patching during packaging +- Gateway sibling path: `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-gateway.exe` +- Gateway sibling last modified: `2026-09-07T09:40:10Z` +- Gateway sibling size: 14,477,824 bytes +- Gateway sibling SHA-256: `D79453C2D91A6AF921C93C861624C8CCA4AC31497E1AF19AA38E458849E119DC` +- Gateway verification: installed, staged, and release SHA-256 hashes match +- Identity verification observed: `2026-09-07T09:42:42.8152106Z` + +### Installed application launch + +- Launched: `2026-09-07T09:42:51.6396972Z` +- Readiness-window observation: `2026-09-07T09:43:11.7819091Z` +- Workshop process ID: 79208 +- Gateway process ID: 60656 +- Both process paths resolve under `C:\Users\Vinnie\AppData\Local\PromptForge` +- Both processes remained running at `2026-09-07T09:43:59.6143596Z` +- No physical microphone, model-menu, or model-turn checklist item was observed during automated preparation + +## Prior installed preparation before Steps 34 and 35 + +### Source and release Gateway + +- Current HEAD: `aeec7b48fad441f42e5b66ec09274f50455180eb` +- Command: `$env:RUSTUP_TOOLCHAIN='stable'; cargo build --release --locked -p gateway` +- Result: passed +- Summary: release profile finished in 27.14 seconds with 9 `gateway-stt` warnings +- Build started: `2026-09-07T07:22:02.4562698Z` +- Build finished: `2026-09-07T07:22:29.7203501Z` +- Artifact: `target/release/promptforge-gateway.exe` +- Last modified: `2026-09-07T07:22:29.1910374Z` +- Size: 14,459,904 bytes +- SHA-256: `8A1D73EDD4BE102482B5B7DAF253B09F6D90C51EA0EA13EE439ECAA4E9DF5DEB` + +### Target-suffixed sidecar + +- Command: `$triple='x86_64-pc-windows-msvc'; New-Item -ItemType Directory -Path 'crates\workshop\binaries' -Force | Out-Null; Copy-Item 'target\release\promptforge-gateway.exe' "crates\workshop\binaries\promptforge-gateway-$triple.exe" -Force` +- Artifact: `crates/workshop/binaries/promptforge-gateway-x86_64-pc-windows-msvc.exe` +- Size: 14,459,904 bytes +- SHA-256: `8A1D73EDD4BE102482B5B7DAF253B09F6D90C51EA0EA13EE439ECAA4E9DF5DEB` +- Verification: source and staged SHA-256 hashes matched at `2026-09-07T07:22:35.0127460Z` + +### Packaging tool + +- Command: `$env:RUSTUP_TOOLCHAIN='stable'; cargo install tauri-cli --locked` +- Result: passed +- Installed version: `tauri-cli 2.11.4` +- Detail: Cargo reported that the same version was already installed +- Verified: `2026-09-07T07:25:14.1899756Z` + +### Fresh unsigned local NSIS package + +- Authorized command: `cargo tauri build --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":false}}'` +- Working directory: `crates/workshop` +- Result: passed +- Successful build started: `2026-09-07T07:22:58.5565020Z` +- Successful build finished: `2026-09-07T07:24:11.0138047Z` +- Installer: `target/release/bundle/nsis/PromptForge_0.2.0_x64-setup.exe` +- Installer created: `2026-09-07T07:23:52.9874435Z` +- Installer last modified: `2026-09-07T07:24:10.9102655Z` +- Installer size: 12,374,918 bytes +- Installer SHA-256: `CFBB5CBB539BE6B77FAB17BC9030E76CBA3D55B1DB21E84D5BD95CAF08E52606` +- Previous installer SHA-256: `750DBEF0F96FC9AE4364942856E558E2D951731CEE8BDC607397A36A457599AB` +- Freshness proof: the installer creation and modification timestamps follow the successful build start, and its hash differs from the previous installer +- Override scope: `bundle.createUpdaterArtifacts=false` was supplied only through the Tauri command line +- Protected release configuration: `crates/workshop/tauri.conf.json` and `.github/workflows/release-workshop.yml` have no diff +- Signing: not tested + +The adjacent `PromptForge_0.2.0_x64-setup.exe.sig` remains stale from `2026-09-06T02:42:54.1030502Z` and is excluded from this build's evidence. + +### Silent installation and installed identities + +- Install result: passed +- Installer exit code: 0 +- Install started: `2026-09-07T07:24:18.0065320Z` +- Install finished: `2026-09-07T07:24:21.3903424Z` +- Workshop path: `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-workshop.exe` +- Workshop version output: `promptforge-workshop 0.2.0` +- Workshop last modified: `2026-09-07T07:23:50Z` +- Workshop size: 24,245,248 bytes +- Workshop SHA-256: `42E7D7500425F91AE576F4E1CAE5E11DE0B606EF1836E8EC1A2EEABD059B7A73` +- Build-tree Workshop SHA-256: `A890D75817337D68A1E8660F8B11F11E7216A9D71C05625A712E9193E8E35CBD` +- Workshop comparison: both identities are recorded without claiming equality because the Tauri log records NSIS bundle-information patching during packaging +- Gateway sibling path: `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-gateway.exe` +- Gateway sibling last modified: `2026-09-07T07:22:28Z` +- Gateway sibling size: 14,459,904 bytes +- Gateway sibling SHA-256: `8A1D73EDD4BE102482B5B7DAF253B09F6D90C51EA0EA13EE439ECAA4E9DF5DEB` +- Gateway verification: installed, staged, and release SHA-256 hashes match +- Identity verification observed: `2026-09-07T07:24:36.4332156Z` + +### Installed application launch + +- Launched through Windows Explorer for operator handoff: `2026-09-07T07:26:12.8249425Z` +- Readiness-window observation: `2026-09-07T07:26:25.7625183Z` +- Separate post-handoff observation: `2026-09-07T07:26:37.3855897Z` +- Workshop process ID: 83436 +- Gateway process ID: 98380 +- Both process paths resolve under `C:\Users\Vinnie\AppData\Local\PromptForge` +- No physical microphone or model-turn checklist item was observed during automated preparation + +## Completed automated prerequisites + +### Gateway Realtime STT + +- Command: `cargo test -p gateway --test it realtime_stt` +- Result: passed +- Summary: 9 passed, 0 failed, 0 ignored, 78 filtered out +- Finished: `2026-09-07T01:55:48.700Z` + +### Workshop Realtime relay + +- Command: `cargo test -p workshop-server --test it realtime_relay` +- Result: passed +- Summary: 7 passed, 0 failed, 0 ignored, 30 filtered out +- Finished: `2026-09-07T01:56:27.864Z` + +### Workshop UI + +- Working directory: `crates/workshop-server/ui` +- Command: `npm test` +- Result: passed +- Summary: 71 passed, 0 failed, 0 cancelled, 0 skipped +- Finished: `2026-09-07T01:56:05.209Z` + +## Completed release preparation + +### Release Gateway + +- Command: `$env:RUSTUP_TOOLCHAIN='stable'; cargo build --release --locked -p gateway` +- Result: passed +- Summary: release profile finished in 1 minute 52 seconds with 9 `gateway-stt` warnings +- Finished: `2026-09-07T01:58:30.071Z` +- Artifact: `target/release/promptforge-gateway.exe` +- Size: 14,457,344 bytes +- SHA-256: `7211CA8E7D71533274EADD6264A77781D1F893B746B76EB8A560280770A7120F` + +### Target-suffixed sidecar + +- Command: `$triple='x86_64-pc-windows-msvc'; New-Item -ItemType Directory -Path 'crates\workshop\binaries' -Force | Out-Null; Copy-Item 'target\release\promptforge-gateway.exe' "crates\workshop\binaries\promptforge-gateway-$triple.exe" -Force` +- Result: passed +- Artifact: `crates/workshop/binaries/promptforge-gateway-x86_64-pc-windows-msvc.exe` +- Size: 14,457,344 bytes +- SHA-256: `7211CA8E7D71533274EADD6264A77781D1F893B746B76EB8A560280770A7120F` +- Verification: source and staged SHA-256 hashes match + +### Packaging tool + +- Command: `$env:RUSTUP_TOOLCHAIN='stable'; cargo install tauri-cli --locked` +- Result: passed +- Installed version: `tauri-cli 2.11.4` +- Detail: Cargo reported that the same version was already installed +- Finished: `2026-09-07T01:58:42.463Z` + +## Unsigned local NSIS build + +- Authorized command: `cargo tauri build --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":false}}'` +- Working directory: `crates/workshop` +- Result: passed +- Build started: `2026-09-07T02:17:35.0140268Z` +- Build finished: `2026-09-07T02:19:29.0773256Z` +- Installer: `target/release/bundle/nsis/PromptForge_0.2.0_x64-setup.exe` +- Installer created: `2026-09-07T02:19:10Z` +- Installer last modified: `2026-09-07T02:19:28Z` +- Installer size: 12,360,736 bytes +- Installer SHA-256: `D045D0C4F57702AB42C93BBE1CEEC810A900C1952DAD1DE456011854899E5520` +- Freshness proof: installer creation and modification timestamps are later than the successful attempt's start timestamp +- Override scope: `bundle.createUpdaterArtifacts=false` was supplied only on the Tauri command line +- Protected files: `crates/workshop/tauri.conf.json` and `.github/workflows/release-workshop.yml` have no diff +- Signing: not tested + +The successful unsigned attempt did not create a current `.sig` file. The adjacent `PromptForge_0.2.0_x64-setup.exe.sig` is stale from `2026-09-06T02:42:54Z` and is excluded from this run's evidence. + +## Silent installation + +- Command: `Start-Process $setup.FullName -ArgumentList '/S' -Wait -PassThru` +- Result: passed +- Installer exit code: 0 +- Started: `2026-09-07T02:19:41.9986519Z` +- Finished: `2026-09-07T02:19:45.3941424Z` + +## Installed sibling verification + +### Workshop + +- Path: `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-workshop.exe` +- Size: 24,210,432 bytes +- SHA-256: `B0B8B3A7D732CBF3B1982AF1CB588DB20F3A0BECD5ED5A508F65EF6E26459B28` +- Version output: `promptforge-workshop 0.2.0` +- Sibling Gateway present: yes + +The Workshop build-tree executable has the same size but SHA-256 `169639C71AD94018FCA0F37E7977B607508EBDE59FE89B5DB4EB34A85366361C`. This is not treated as an applicable byte-for-byte comparison because the Tauri log records patching the Workshop executable with NSIS bundle information during packaging. Both hashes are recorded rather than claiming equality. + +### Gateway + +- Path: `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-gateway.exe` +- Size: 14,457,344 bytes +- SHA-256: `7211CA8E7D71533274EADD6264A77781D1F893B746B76EB8A560280770A7120F` +- Staged sidecar SHA-256: `7211CA8E7D71533274EADD6264A77781D1F893B746B76EB8A560280770A7120F` +- Release Gateway SHA-256: `7211CA8E7D71533274EADD6264A77781D1F893B746B76EB8A560280770A7120F` +- Verification: installed, staged, and release Gateway hashes match + +## Installed application launch + +- Launched: `2026-09-07T02:20:39.1019801Z` +- Workshop process ID: 78444 +- Gateway sibling process observed: yes +- Gateway process ID: 101204 +- Both installed processes remained running at the automated handoff + +## Second installed attempt after no-model-turn fix + +### Source and process boundary + +- Current HEAD: `49441166f580a3d6339532a3c1fd3c1205e484cd` +- Installed processes observed before rebuild: 0 +- Installed processes stopped: 0 +- Installed processes remaining before rebuild: 0 +- Process boundary observed: `2026-09-07T04:26:54.2378279Z` + +### Release Gateway and staged sidecar + +- Command: `$env:RUSTUP_TOOLCHAIN='stable'; cargo build --release --locked -p gateway` +- Result: passed +- Summary: release profile finished in 49.18 seconds with 9 `gateway-stt` warnings +- Finished: `2026-09-07T04:27:44.975Z` +- Release Gateway: `target/release/promptforge-gateway.exe` +- Release Gateway last modified: `2026-09-07T04:27:42.7135449Z` +- Release Gateway size: 14,457,344 bytes +- Release Gateway SHA-256: `7BE1C818B196A1C889ADE75D8AA09847404808C6530FFC7662DBBC26E10BAD18` +- Staged sidecar: `crates/workshop/binaries/promptforge-gateway-x86_64-pc-windows-msvc.exe` +- Staged sidecar size: 14,457,344 bytes +- Staged sidecar SHA-256: `7BE1C818B196A1C889ADE75D8AA09847404808C6530FFC7662DBBC26E10BAD18` +- Staging verified: `2026-09-07T04:27:55.0750924Z` +- Verification: current release and staged Gateway hashes match + +### Fresh unsigned local NSIS package + +- Authorized command: `cargo tauri build --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":false}}'` +- Working directory: `crates/workshop` +- Result: passed +- Previous installer last modified: `2026-09-07T02:19:28.9716724Z` +- Previous installer SHA-256: `D045D0C4F57702AB42C93BBE1CEEC810A900C1952DAD1DE456011854899E5520` +- Build started: `2026-09-07T04:28:04.7578431Z` +- Build finished: `2026-09-07T04:29:35.1883254Z` +- Current installer: `target/release/bundle/nsis/PromptForge_0.2.0_x64-setup.exe` +- Current installer created: `2026-09-07T04:29:17Z` +- Current installer last modified: `2026-09-07T04:29:35Z` +- Current installer size: 12,359,149 bytes +- Current installer SHA-256: `750DBEF0F96FC9AE4364942856E558E2D951731CEE8BDC607397A36A457599AB` +- Freshness proof: the current installer creation and modification timestamps follow this attempt's start, and its hash differs from the previous installer +- Override scope: `bundle.createUpdaterArtifacts=false` was supplied only on the Tauri command line +- Protected release configuration: unchanged +- Signing: not tested + +### Second silent installation + +- Result: passed +- Installer exit code: 0 +- Started: `2026-09-07T04:29:47.7712059Z` +- Finished: `2026-09-07T04:29:51.1584956Z` + +### Second installed identities + +- Workshop path: `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-workshop.exe` +- Workshop version output: `promptforge-workshop 0.2.0` +- Workshop file version: `0.2.0` +- Workshop last modified: `2026-09-07T04:29:14Z` +- Workshop size: 24,211,456 bytes +- Workshop SHA-256: `AD2A99A912F016C7B11B37DEAA29DF6A04AB2292BD8C56E1C3D6065585D29B35` +- Build-tree Workshop SHA-256: `A0638A0B723997026CFE92DC056597E13D83DBB032B18B8D7453DC2AE513246F` +- Workshop comparison: both identities are recorded without claiming equality because the Tauri log records NSIS bundle-information patching during packaging +- Gateway sibling path: `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-gateway.exe` +- Gateway sibling last modified: `2026-09-07T04:27:42Z` +- Gateway sibling size: 14,457,344 bytes +- Gateway sibling SHA-256: `7BE1C818B196A1C889ADE75D8AA09847404808C6530FFC7662DBBC26E10BAD18` +- Gateway verification: installed, staged, and release SHA-256 hashes match +- Identity verification observed: `2026-09-07T04:30:01.8473944Z` + +### Second installed launch + +- Launched: `2026-09-07T04:30:08.4151156Z` +- Workshop process ID: 48564 +- Gateway process ID: 59724 +- Both process paths resolve under `C:\Users\Vinnie\AppData\Local\PromptForge` +- Both processes remained running at `2026-09-07T04:30:16.5398864Z` + +## Operator observations - later build-tree launch + +- Observed: approximately `2026-09-07T06:33Z` through `2026-09-07T06:36Z` +- Acceptance applicability: none; process inspection showed both Workshop and Gateway running from `C:\Users\Vinnie\cursor\promptforge\target\release`, not the installed `AppData\Local\PromptForge` paths +- Gateway readiness: serving at `06:33:11Z`, speech ready with GPU and profile switched by `06:33:13Z`, `claude-opus-4-6` advertised, chat endpoint ready +- Workshop model catalog: failed; the picker exposed no model even though Gateway advertised `claude-opus-4-6` +- Realtime connection: failed latency; microphone readiness took approximately 20 to 30 seconds +- Live hypotheses: failed; no text evolved while recording +- Completion: functional; correct text appeared only after stop +- Status lifecycle: failed; the progress bar remained visible after profile completion and the normal LEDs did not return +- Diagnosis: Workshop refreshed model state before Gateway profile publication and did not retry while health stayed reachable; precommit hypothesis IDs were not bound to the active take; the imported Gateway progress operation remained attached to the never-ending SSE stream after its root finished + +## Prior failed observations - first installed attempt + +### Dictation + +- Observed: approximately `2026-09-07T03:55Z` through `2026-09-07T03:57Z` +- Result: partial success, latency failure +- Evidence: the installed Workshop first displayed `Dictation is connecting. Try again in a moment.`, then eventually inserted `Tell me a story, is it gonna work? I don't think it's gonna work.` +- Connection delay: 5 to 15 seconds +- Stop-to-final delay: 5 to 15 seconds +- Verdict: the installed speech path works functionally, but both observed delays exceed the two-second acceptance budget + +### Model turn after dictation + +- Observed: approximately `2026-09-07T03:57Z` +- Result: failed +- Evidence: the model picker still displayed `Select model`; submission persisted the user message and a tool result containing the dictated text, but no assistant message followed +- Session log: `dd27eef4544a74d2b12e9f1a25251000` +- Gateway state during diagnosis: running, profile `default`, `claude-opus-4-6` advertised, chat endpoint ready, speech ready with GPU, no active or pending command +- Network state during diagnosis: Workshop retained local Gateway connections, while Gateway held no outbound provider connection +- Verdict: no Anthropic request was reached; the local no-model binding error returned through Lua `pcall` without an operator-visible response + +## Operator observation checklist retained for audit context + +This checklist records the detail originally requested for the post-Step 37 build. Unchecked items were not individually recorded and are not retroactively claimed as measured. The post-Step 37 verdict below remains historical evidence and does not complete the later Step 42 repeat. + +- [ ] Confirm both chat model menus, the inline dropdown and the top-level `Model` menu, show only chat-capable models and do not list speech-only models. +- [ ] Select `claude-opus-4-6`, submit typed input, and confirm the selected Claude model completes the turn with an assistant response. +- [ ] Confirm live speech revisions replace rather than duplicate provisional text, with exact spacing preserved. +- [ ] Confirm completion commits the final transcript exactly once. +- [ ] Start a second take and confirm it is independent of the first. +- [ ] Clear the transcript and confirm the visible and retained take state clears. +- [ ] Cancel an active take and confirm no later hypothesis or completion is applied. +- [ ] Deny microphone permission or select an unavailable device, confirm a recoverable error, restore access, and confirm a new take works. +- [ ] Measure connection delay from microphone activation to ready capture. +- [ ] Measure stop-to-final delay from stop action to committed final transcript. +- [ ] Record the installed Workshop path, sibling Gateway path, installer path, sizes, SHA-256 hashes, and UTC timestamps. + +Automated preparation did not perform the checklist. The operator later observed the post-Step 37 installed build and accepted it as recorded below, without supplying measurements or item-by-item results beyond those stated. + +## Operator acceptance - post-Step 37 installed build + +- Observed: approximately `2026-09-07T12:15Z` +- Build under test: installed unsigned package built from `2d1ecca8` +- Short-utterance Stop regression: passed; repeated utterances with the last word spoken immediately before Stop retained the correct final word +- Live transcription: passed; operator reported the repaired behavior works correctly +- Overall operator verdict: `Works correctly. Accepted.` +- Signing: not tested; release signing remains a release-CI gate + +## Operator observations - post-Step 36 installed attempt + +- Observed: approximately `2026-09-07T10:59Z` +- Live hypotheses: substantially improved; the prior repeated-phrase accumulation was not observed +- Stop finalization: failed intermittently; a correct word appeared in the latest live hypothesis, then pressing Stop removed that word from the authoritative completion +- Verdict: cadence and whole-window rebasing improved the live path, but Step 37 remains failed because completion can discard recognized audio-backed tail text + +## Operator observations - post-Steps 34 and 35 installed attempt + +- Observed: approximately `2026-09-07T09:47Z` +- Chat model menus: passed; speech-only models no longer appeared +- Typed model turn: passed; selected chat model responded +- Live hypotheses: failed; provisional text still accumulated repeated phrases while recording instead of presenting one evolving replacement +- Completion: prior behavior indicates Stop replaces provisional text with the clean authoritative final, but the full completion checklist was not repeated in this observation +- Verdict: Step 34 repairs passed installed observation; Step 35 did not repair the real native interim sequence, so Step 36 remains failed + +## Prior failed observations - pre-Steps 34 and 35 installed attempt + +- Observed: approximately `2026-09-07T08:45Z` +- Model catalog: `claude-opus-4-6` was visible and selected +- Chat model menus: failed filtering; both the inline dropdown and top-level `Model` menu listed `whisper-base-en`, `whisper-small-en`, and `realtime-transcribe`, which are speech models and must not be selectable for chat +- Typed model turn: failed; submitting `test 1 2 3` persisted the user input and tool result, then displayed `Error: Model turn failed in agent 'chat'` +- Model-turn diagnosis: Workshop launched the built-in chat session before Gateway published its profile models, freezing an empty session model catalog; later catalog convergence updated the picker but not that running session, so binding failed locally before any Gateway completion request +- Live hypotheses: failed replacement behavior; revisions appeared while recording but accumulated repeatedly in the editor +- Completion: functional replacement; pressing Stop removed the duplicated provisional text and left the correct final transcript +- Verdict: Step 34 remains failed; model-session catalog convergence and live ProseMirror range replacement require repair before acceptance can be repeated + +## Step 42 full release verification at HEAD d79823ed + +### Run boundary + +- Current commit: `d79823ed723b155a77d704e8861c1f1e7e00e6c1` (`Bookend Gateway serving file logs`) +- Shell: Windows PowerShell `5.1.26100.9278` +- Initial gate toolchain: Cargo `1.89.0`; release commands explicitly selected stable +- Other tools: Node `v24.19.0`, npm `11.17.0`, mdBook `0.4.44`, Tauri CLI `2.11.4`, cargo-deny `0.20.2`, cargo-modules `0.25.0`, cargo-public-api `0.52.0` +- Initial worktree: clean +- Commit created: no + +### Rust, policy, build, and native gates + +- Command: `cargo fmt --all --check` + - Result: passed, exit code 0, 3.815 seconds, no output +- Command: `cargo clippy --workspace --all-targets --all-features -- -D warnings` + - Result: passed, exit code 0, 34.739 seconds + - Summary: finished the development profile in 32.48 seconds with no warning or error +- Command: `cargo test --workspace` + - Result: passed, exit code 0, 265.410 seconds + - Summary: every workspace unit, integration, binary, and documentation target completed without a failed test +- Command: `cargo test --workspace --all-features --doc` + - Result: passed, exit code 0, 174.292 seconds + - Summary: every all-feature documentation target completed without a failed test +- Command: `$env:RUSTDOCFLAGS='-D warnings'; cargo doc --workspace --no-deps --all-features` + - Result: passed, exit code 0, 19.744 seconds + - Summary: finished in 17.63 seconds and generated 33 documented workspace entries with warnings denied +- Command: `cargo deny check` + - Result: passed, exit code 0, 11.997 seconds + - Exact terminal summary: `advisories ok, bans ok, licenses ok, sources ok` + - Permitted warnings included duplicate and wildcard dependency reports, one license-not-encountered report, and yanked `chacha20 0.10.1` +- Command: `cargo build -p gateway` + - Result: passed, exit code 0, 7.171 seconds + - Summary: finished in 5.22 seconds with 11 default-feature `gateway-stt` unused or dead-code warnings +- Command: `cargo build -p workshop` + - Result: passed, exit code 0, 39.271 seconds + - Summary: finished in 37.20 seconds +- Command: `cargo check -p gateway --no-default-features` + - Result: passed, exit code 0, 3.776 seconds + - Summary: featureless Gateway finished in 1.71 seconds +- External native prerequisite check: + - `C:\Users\Vinnie\cursor\promptforge\local\stt-fixtures\whisper.dll`: present, 1,368,064 bytes + - `C:\Users\Vinnie\cursor\promptforge\crates\gateway-stt-backend-whisper\tests\fixtures\ggml-tiny.en.bin`: present, 77,704,715 bytes + - `C:\Users\Vinnie\cursor\promptforge\crates\gateway-stt-backend-whisper\tests\fixtures\jfk.wav`: present, 352,078 bytes +- Command: `$fixture=(Resolve-Path 'local\stt-fixtures').Path; $env:PATH="$fixture;$env:PATH"; $env:PROMPTFORGE_WHISPER_LIBRARY=(Resolve-Path 'local\stt-fixtures\whisper.dll').Path; cargo test -p gateway-stt-backend-whisper --test native_whisper -- --ignored` + - Result: passed, exit code 0, 9.969 seconds + - Native equivalence: 5 passed, 0 failed, 0 ignored, including the fixed JFK transcript, glossary and transcript conditioning, stateless-job independence, absent-final classification, and model-progress terminals +- Command: `cargo +nightly-2026-09-05 miri test -p gateway-stt-engine --features test-fixtures miri_` + - Result: passed, exit code 0, 10.565 seconds + - Summary: 2 passed, 0 failed, 20 filtered out; contract and cleanup targets had 0 selected tests +- Command: `cargo +nightly-2026-09-05 miri test -p gateway-stt --features test-fixtures miri_` + - Result: passed, exit code 0, 41.628 seconds + - Summary: 11 passed, 0 failed, 54 filtered out + +### Architecture ratchets + +- Command: `node tools/check-stt-architecture.mjs` + - Result: passed, exit code 0, 40.759 seconds + - `gateway-stt`: acyclic, 6 public roots, 43 source modules, largest module 481 lines at `realtime/session.rs` + - `gateway-stt-engine`: acyclic, 7 public roots, 10 source modules, largest module 460 lines at `worker.rs` + - `gateway-stt-backend-whisper`: acyclic, 2 public roots, 4 source modules, largest module 293 lines at `model.rs` + - `gateway-whisper-ffi`: acyclic, 6 public roots, 7 source modules, largest module 226 lines at `context.rs` +- Command: `cargo test -p gateway-stt --test it architecture` + - Result: passed, exit code 0, 2.623 seconds + - Summary: 16 passed, 0 failed, 40 filtered out; exact final dependencies, exact ceilings, final migration state, unsafe isolation, generation ownership, and legacy seam removal all passed + +### UI gates + +- Workshop UI command: `npm run typecheck` + - Result: passed, exit code 0, 3.531 seconds; `check-layers: ok` +- Workshop UI command: `npm run build` + - Result: passed, exit code 0, 3.329 seconds; emitted `dist/app.js` at 2.5 MiB and `dist/app.css` at 166.0 KiB +- Workshop UI command: `npm test` + - Result: passed, exit code 0, 9.077 seconds; 69 passed, 0 failed, 0 cancelled, 0 skipped +- Gateway config UI command: `npm run typecheck` + - Result: passed, exit code 0, 3.316 seconds +- Gateway config UI command: `npm run build` + - Result: passed, exit code 0, 2.713 seconds; emitted `dist/app.js` at 291.4 KiB and `dist/app.css` at 37.7 KiB +- Gateway config UI command: `npm test` + - Result: passed, exit code 0, 18.080 seconds; `check-layers: ok`; 128 passed, 0 failed, 0 cancelled, 0 skipped + +### Generated documentation + +- Command: `cargo run -p build-user-guide` + - Result: passed, exit code 0, 2.195 seconds +- Command: `mdbook build guide` + - Result: passed, exit code 0, 2.272 seconds; HTML backend completed +- Command: `git diff --exit-code -- guide/src/SUMMARY.md guide/src/gateway/index.md guide/src/workshop/index.md guide/src/language/index.md guide/src/agent/index.md guide/promptforge-gateway-guide.md guide/promptforge-workshop-guide.md guide/promptforge-language-guide.md guide/promptforge-agent-guide.md` + - Result: passed, exit code 0, with only Git line-ending notices for the Workshop and Agent single-file guides +- Generated-doc cleanliness: all nine SHA-256 values were identical before and after regeneration: + - `guide/src/SUMMARY.md`: `4031AACD9459ED213C3E5D41466993691FD8B2DA07DEC9D090D90E8493F99FFC` + - `guide/src/gateway/index.md`: `09E9807249611001CA6CAF2A1A210BF64E2B843C4C6B6A8C7284068F6E44B2D2` + - `guide/src/workshop/index.md`: `4BC7756A0D6D66807061BD747C72618096B24ACC5031F536F12B4019C53F4226` + - `guide/src/language/index.md`: `41E9E4458BC1ED9F969F0DB7E13C24A3D94A4E88253D88239E6AA3F40F631AFD` + - `guide/src/agent/index.md`: `1C66A4A2EF1AF16AE38668F2D0910124F20520E714610EA8B150C282ECAC623E` + - `guide/promptforge-gateway-guide.md`: `5BF95CD9776A87982E13D9C6E7DA09F7BED2375292E75919F963BF59F497BCDE` + - `guide/promptforge-workshop-guide.md`: `F45DB5FBAE9B56CB4415218CBA2B8D12EEAB39B45DEF10922349C369E2921DF0` + - `guide/promptforge-language-guide.md`: `3CDF6E562EF45AC8873703C81701834650CAC7E8A8459839E96466D03AF16DFA` + - `guide/promptforge-agent-guide.md`: `3B70D4DE22FF4077BC31D9E484BC8672DDF256413795B6BD8708774DB29464F9` + +### Fresh unsigned NSIS package + +- Command: `$env:RUSTUP_TOOLCHAIN='stable'; cargo build --release --locked -p gateway` + - Result: passed, exit code 0, 42.019 seconds; release profile finished in 39.64 seconds with 11 `gateway-stt` warnings +- Release Gateway: + - Path: `C:\Users\Vinnie\cursor\promptforge\target\release\promptforge-gateway.exe` + - Last modified: `2026-09-07T13:53:06.4583106Z` + - Size: 13,405,696 bytes + - SHA-256: `C60DFDEBC6E45EEE82AF6952FF81CF1B0A1F0B92ADC8EA1BFE68F82105E8C06B` +- Command: `$triple='x86_64-pc-windows-msvc'; New-Item -ItemType Directory -Path 'crates\workshop\binaries' -Force | Out-Null; Copy-Item 'target\release\promptforge-gateway.exe' "crates\workshop\binaries\promptforge-gateway-$triple.exe" -Force` + - Result: passed, exit code 0 + - Staged sidecar size and SHA-256 exactly matched the release Gateway +- Command: `$env:RUSTUP_TOOLCHAIN='stable'; cargo install tauri-cli --locked` + - Result: passed, exit code 0, 2.351 seconds; Tauri CLI `2.11.4` was already installed +- Exact PowerShell 5.1 package command: `$env:RUSTUP_TOOLCHAIN='stable'; cargo --% tauri build --bundles nsis --config {\"bundle\":{\"createUpdaterArtifacts\":false}}` + - Result: passed, exit code 0, 107.781 seconds; release profile finished in 1 minute 11 seconds and produced one NSIS bundle + - Override scope: `bundle.createUpdaterArtifacts=false` was supplied only through the command line + - Protected release configuration: `crates/workshop/tauri.conf.json` and `.github/workflows/release-workshop.yml` remained clean +- Fresh installer: + - Path: `C:\Users\Vinnie\cursor\promptforge\target\release\bundle\nsis\PromptForge_0.2.0_x64-setup.exe` + - Created: `2026-09-07T13:55:16.3424357Z` + - Last modified: `2026-09-07T13:55:37.7434332Z` + - Size: 12,034,196 bytes + - SHA-256: `194D2D6D86C6E552E12E1E7D96E5469914FE11E588B123E6313833E0C49A9F78` + - Previous installer SHA-256: `CE476DE44A6F7E0897765ED45AA6E988702826FC9F4B7083A155DBE90E90F028` + - Freshness: creation and modification followed the package start, and the hash changed + - Signing: not tested; adjacent `.sig` is stale from `2026-09-06T02:42:54.1030502Z` and is excluded + +### Installation + +- Command: `$setup=Get-ChildItem -Recurse 'target\release\bundle\nsis' -Filter '*-setup.exe' | Select-Object -First 1; if (-not $setup) { throw 'no NSIS installer' }; $process=Start-Process $setup.FullName -ArgumentList '/S' -Wait -PassThru; if($process.ExitCode -ne 0){throw "installer exited $($process.ExitCode)"}` + - Result: passed; installer exit code 0 +- Installed Workshop: + - Path: `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-workshop.exe` + - Last modified: `2026-09-07T13:55:14Z` + - Size: 24,186,880 bytes + - SHA-256: `94BBB68A5E0C71CE6FFD0FA5014C13E0DF9B17ECE15C80A8B1544991ED7EBCCB` + - Product version: `0.2.0` +- Installed Gateway: + - Path: `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-gateway.exe` + - Last modified: `2026-09-07T13:53:06Z` + - Size: 13,405,696 bytes + - SHA-256: `C60DFDEBC6E45EEE82AF6952FF81CF1B0A1F0B92ADC8EA1BFE68F82105E8C06B` + - Native package equivalence: installed, staged, and release Gateway hashes match exactly + +### Installed launch and operator boundary + +- Installed Workshop launch: passed +- Handoff observed: `2026-09-07T14:00:32.5494104Z` +- Workshop process: PID 92768 at `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-workshop.exe` +- Gateway process: PID 65268 at `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-gateway.exe` +- Readiness observation: both installed processes remained running 20 seconds after launch +- Automated Step 42 gates: passed +- Physical observation completed: microphone recording and chat turns on the reopened installed Workshop +- Remaining physical scenarios: + - Confirm a second take is independent + - Confirm clear removes visible and retained take state + - Confirm cancellation prevents later hypothesis or completion application + - Confirm permission denial or unavailable-device failure is recoverable, then restore access and complete a new take +- Signing: not tested +- Handoff note: after the recorded readiness check the Workshop window closed while Gateway PID 65268 remained running. The installed Workshop was reopened as PID 73520 after the `2026-09-07T14:00:32.5494104Z` handoff and before the approximately `2026-09-07T14:03Z` observation. Its exact process start timestamp was not retained, and PID 73520 is no longer running. + +### Final installed operator acceptance + +- Observed: approximately `2026-09-07T14:03Z` +- Package under test: the fresh Step 42 unsigned installer recorded above +- Installed Workshop process: PID 73520 +- Observed scope: microphone recording and chat turns +- Operator verdict for that scope: `works beautifully` +- Not independently observed: second-take independence, Clear, cancellation, and permission-denial or unavailable-device recovery +- Physical acceptance: incomplete pending those four scenarios +- Signing: not tested; release signing remains a release-CI gate diff --git a/design/generic-realtime-stt.md b/design/generic-realtime-stt.md new file mode 100644 index 00000000..89c380ef --- /dev/null +++ b/design/generic-realtime-stt.md @@ -0,0 +1,111 @@ +# Generic Realtime speech-to-text architecture + +## Outcome + +PromptForge exposes speech as a Gateway product capability. The Gateway owns speech artifacts, models, workers, profile replacement, batch transcription, and Realtime transcription. Workshop is an independent consumer: its server authenticates and relays one fixed Realtime target, and its browser UI owns microphone capture and transcript presentation. + +The only live streaming endpoint is `WS /v1/realtime?intent=transcription`. The removed `/stt` and `/stt/capability` routes, Workshop-specific status frames, and custom status headers have no compatibility path. + +## Component boundaries + +- `gateway` owns route mounting, authentication, profile-switch transactions, model discovery, and operational status. +- `gateway-stt` is the cloneable speech facade. It owns artifact preparation, complete generation snapshots, batch and Realtime routes, session and item orchestration, take state, segmentation, hypothesis agreement, transcript aggregation, and wire translation. +- `gateway-stt-engine` owns backend-neutral decoder contracts, stateless decode jobs, bounded serialized workers, startup deadlines, cancellation observation, and joined shutdown. +- `gateway-stt-backend-whisper` owns safe Whisper construction, checked configuration, prompt fitting, decode parameters, native-load progress, and backend error translation. +- `gateway-whisper-ffi` is the only unsafe STT crate. It owns runtime-loaded C symbols, ABI layouts, native pointers, and their lifetimes. +- `shared-loopback` owns distinct Gateway loopback-Origin and Workshop same-origin-authority policies. +- `workshop-server` owns only the authenticated, payload-opaque Realtime relay. The Workshop UI owns capture, connection recovery, hypothesis replacement, and user-visible dictation status. + +The core direction is: + +```text +gateway -> gateway-stt -> gateway-stt-engine + | + +-> gateway-stt-backend-whisper + | + +-> gateway-stt-engine + +-> gateway-whisper-ffi + +workshop-server -> shared-loopback +``` + +No Gateway STT crate depends on Workshop. No Workshop crate depends on a Gateway STT implementation crate. + +## Exact workspace dependency policy + +- `gateway` -> `gateway-config`, `gateway-config-ui`, `gateway-local`, `gateway-logging`, `gateway-routing`, `gateway-stt`, `gateway-web-search`, `promptforge-core`, `shared-loopback`, `shared-progress`, `shared-protocol`, `shared-sidecar` +- `gateway-stt` -> `gateway-config`, `gateway-local`, `gateway-stt-backend-whisper`, `gateway-stt-engine`, `shared-progress` +- `gateway-stt-engine` -> none +- `gateway-stt-backend-whisper` -> `gateway-stt-engine`, `gateway-whisper-ffi`, `shared-progress` +- `gateway-whisper-ffi` -> none +- `shared-loopback` -> none +- `workshop-server` -> `build-ui`, `promptforge-agent`, `promptforge-core-support`, `promptforge-model-client`, `promptforge-store`, `promptforge-tools`, `shared-loopback`, `shared-progress`, `shared-sidecar` + +The architecture test reads Cargo metadata across normal, development, target-specific, and build dependencies. Any extra or missing workspace edge fails. + +## Public surfaces + +The final effective crate-root counts are exact: + +- `gateway-stt`: 6 +- `gateway-stt-engine`: 7 +- `gateway-stt-backend-whisper`: 2 +- `gateway-whisper-ffi`: 6 + +`gateway-stt` exposes the lifecycle facade and opaque supporting facts, not route handlers, wire types, workers, sessions, or takes. The engine exposes only backend-neutral contracts. The safe Whisper backend exposes only its backend and checked configuration. + +Active physical speech model names remain batch selectors. `realtime-transcribe` is a reserved logical name advertised only while a complete interim and final generation is ready. Generic Gateway status reports `configured`, `ready`, `gpu`, and `generation`; builds without STT omit speech status. + +## Realtime contract + +The request query must be exactly `intent=transcription`. Missing, duplicate, malformed, unsupported, or unknown parameters are rejected before upgrade. Gateway authentication runs before the session. A native client may omit Origin; a browser Origin must be HTTP loopback. Workshop separately requires browser Origin authority to match the request authority and constructs the fixed authenticated upstream target itself. + +The supported client events are `session.update`, `input_audio_buffer.append`, `input_audio_buffer.clear`, and `input_audio_buffer.commit`. Session format is signed little-endian mono PCM16 at 24 kHz with null noise reduction and turn detection. The gateway preserves split samples across appends, continuously resamples to 16 kHz, flushes on commit, and fully resets uncommitted input on clear. + +Standard server events cover session creation and updates, commit acknowledgment, item creation, transcription deltas, completion, failure, and errors. Clients may negotiate `item.input_audio_transcription.hypothesis`; the extension emits revisioned replacement snapshots containing the complete transcript and its finalized, agreed, and tentative regions. Completion is authoritative. + +## Ownership and bounds + +One interim worker and optional final worker are shared across clients. Workers retain no session, take, guidance, history, or transcript state between jobs. Each admitted job keeps an explicit generation work guard until cancellation is observed or native decode returns. + +One session owns its uncommitted input and up to four independently finalizing committed items. `Take` is the only per-take abstraction and owns guidance, finalized history, segment aggregation, completion, and failure. Commit preserves the provisional item ID and durable lineage. Clear cancels only uncommitted work. + +The fixed limits are: + +- 8 active Realtime sessions +- 4 committed items per session +- 8 queued interim jobs and 8 queued final jobs +- 16 ordinary session results, plus reserved terminal and replaceable hypothesis slots +- 4 final segments per item +- 8 retained cancellation joins per session +- 15 MiB decoded audio per append +- 30 seconds of uncommitted audio +- 100 ms minimum committed audio + +Capacity and capacity-plus-one tests pin each bound. Authoritative segments and terminal outcomes never use lossy admission. + +## Profile replacement + +Artifact preparation starts no worker. Replacement closes admission, installs a fresh rollback epoch, cancels old work, and waits for explicit request and job ownership to drain. Old workers then shut down and join before the new generation starts under one deadline. + +The new generation remains unpublished until profile persistence succeeds. Persistence prepares and syncs a temporary file, atomically replaces the authoritative state, and syncs the parent where supported. Determinate failure reconstructs the old generation. Indeterminate persistence or non-preemptible startup timeout invalidates staged state and requests controlled process shutdown. Replacement never detaches a native worker or claims cancellation of a non-preemptible native call. + +## Workshop path + +The browser's `SpeechCaptureService` owns the microphone graph and emits little-endian mono PCM16 at 24 kHz. `RealtimeTranscriptionService` owns protocol negotiation and reconnect backoff. The view keeps one reversible editor range per take and replaces that range from hypothesis snapshots until completion. + +The Workshop server exposes `/v1/realtime` on its own origin. It validates Origin, rejects subprotocols, attaches the Gateway credential upstream, preserves text, binary, close code, and close reason, and bounds relay writes. It does not parse speech JSON, report speech capability, or own speech status. + +## Architecture and CI gates + +- `node tools/check-stt-architecture.mjs` pins Cargo 1.89, `cargo-modules` 0.25.0, and `cargo-public-api` 0.52.0; rejects malformed tool output; proves every STT production-library module graph acyclic; and requires exact public-root counts. +- `cargo test -p gateway-stt --test it architecture` enforces exact final workspace edges, exact source manifests, the 500-line maximum for every source module, unsafe isolation, zero legacy speech seams, generic discovery, explicit lifecycle ownership, and transactional replacement. +- Normal CI installs only config UI dependencies before building Gateway, proving the default Gateway build cannot invoke Workshop UI tooling. +- Normal CI runs the architecture driver tests, the architecture driver, and the Rust architecture suite before formatting, linting, and tests. +- Miri runs backend-neutral worker, generation, queue, audio, registry, item, mailbox, and replacement-state targets. Native FFI, callbacks, sockets, and model loading remain on native CI. + +## Debt result + +The initial architecture-ratchet snapshot contained one temporary workspace edge, six migration-target exceptions, three source modules above 500 lines, a maximum module size of 712 lines, and a `gateway-stt` public-root allowance of 9. + +The final snapshot contains zero temporary edges, zero migration exceptions, zero modules above 500 lines, a maximum module size of 481 lines, and exact public-root counts of 6, 7, 2, and 6. The forbidden `gateway-stt -> workshop-server` edge fell from one to zero. The larger final source total reflects the delivered Realtime protocol, ownership, and test surface; the debt measures are responsibility size, dependency direction, cycles, unsafe isolation, and public exposure, all enforced as failing gates. diff --git a/gateway.local.example.toml b/gateway.local.example.toml index 416166ac..10e188fd 100644 --- a/gateway.local.example.toml +++ b/gateway.local.example.toml @@ -15,14 +15,15 @@ api_key = "change-me-to-a-secret" # to require the bearer key from every caller. trust_loopback = true -# Optional embedded Workshop listener. +# Deprecated Workshop hosting settings. They still parse so older files load, +# but the gateway ignores them and logs a warning. [workshop] bind = "127.0.0.1:7910" open_browser = false -# Optional speech capture tuning. STT model files and roles belong in +# Optional speech pipeline tuning. STT model files and roles belong in # [[stt_model]], not this table. -[workshop.stt] +[stt] window_seconds = 15 interval_ms = 500 vocabulary = ["PromptForge", "WG21", "GGUF"] diff --git a/guide/promptforge-gateway-guide.md b/guide/promptforge-gateway-guide.md index 7e9c4628..b61bc30f 100644 --- a/guide/promptforge-gateway-guide.md +++ b/guide/promptforge-gateway-guide.md @@ -22,15 +22,15 @@ promptforge-gateway --version ## Start the gateway -Start the gateway with one subcommand that names a config file and a profile: +Start the gateway by naming a config file and a profile: ```` -promptforge-gateway serve gateway.toml --profile main +promptforge-gateway --config gateway.toml --profile main ```` -The first argument is the path to the config file. The `--profile` flag names the profile to activate. The gateway always starts from one config file and one active profile. +The `--config` flag gives the path to the config file. The `--profile` flag names the profile to activate. The gateway always starts from one config file and one active profile. -You can supply both values through environment variables instead of command-line arguments. The config path comes from the positional argument or from `PROMPTFORGE_GATEWAY_CONFIG`; the command line wins when both are set. The profile comes from `--profile`, then `PROMPTFORGE_PROFILE`, then the sibling state file the gateway keeps beside the config. +You can supply both values through environment variables instead of command-line arguments. The config path comes from `--config` or from `PROMPTFORGE_GATEWAY_CONFIG`; the flag wins when both are set. The profile comes from `--profile`, then `PROMPTFORGE_PROFILE`, then the sibling state file the gateway keeps beside the config. You can also start the gateway with no config file at all. When no `gateway.toml` exists beside the executable, in the working directory, or in the user profile's `.promptforge` directory, the first run writes a default config there - loopback-only on an OS-assigned port, with a fresh random bearer key and `trust_loopback = true` so callers on the same machine need no key - and boots from it. The generated file notes the caveat beside that line: on a shared machine any other OS account can then use the gateway, and `trust_loopback = false` requires the key from everyone. The generated config selects a profile named `default`, so a bare first boot needs no flags. @@ -75,7 +75,7 @@ Build-time feature flags decide which capabilities exist in the binary. The flag On Linux the release archive contains a sample systemd unit. The unit runs the gateway as a service with a fixed config path and profile, and restarts it automatically on failure: ```` -ExecStart=/usr/local/bin/promptforge-gateway serve /etc/promptforge/gateway.toml --profile main +ExecStart=/usr/local/bin/promptforge-gateway --config /etc/promptforge/gateway.toml --profile main Restart=on-failure RestartSec=5 ```` @@ -84,9 +84,15 @@ The gateway holds vendor credentials, so run it as a dedicated unprivileged user ## Watch the logs +A serving gateway logs to `gateway.log` in the `logs` directory under the state directory (`~/.promptforge/logs` on a default install) and mirrors the same stream to stdout. Startup rotates the previous run's log aside - `gateway.log` becomes `gateway.log.1` - and keeps five previous runs, deleting the oldest. Every record crosses a redaction pass before it reaches disk: bearer tokens, authorization and cookie header values, and `api_key` assignments are masked. The log location is never configurable, so a config failure still has somewhere to report itself. + Control log verbosity through the standard `RUST_LOG` environment filter. The speech library logs at warn level by default, so it stays quiet unless you ask for more. -Startup failures appear on stderr with the full cause chain: one `error:` line followed by one `caused by:` line per cause. Once the gateway is serving, the log shows the bound address. If you configured port 0, the log reports the real bound port. +Startup failures appear on stderr with the full cause chain: one `error:` line followed by one `caused by:` line per cause, and the same chain lands in the log file. Once the gateway is serving, the log shows the bound address. If you configured port 0, the log reports the real bound port. + +## Inspect a failed run + +When a gateway run fails before it can serve, `promptforge-gateway diagnostics` finds the evidence without any config knowledge. It prints a read-only JSON report: the state directory, the resolved config path and whether it exists, the current and retained log paths and which exist, the connection file, whether a gateway is running, and the version. It never serves, rotates a log, parses a config, or mutates the state directory, and it never prints secrets - no bearer key, environment value, config content, or log content. The generated config points at it in a comment. ## Stop the gateway @@ -382,7 +388,7 @@ Local chat completions accept deterministic sampling parameters such as `tempera # Speech-to-Text -This chapter teaches you the gateway's transcription surface: how to declare speech models, how the interim and final roles work together, and what the transcription endpoint serves. Speech builds on local models, because speech models are provisioned and cached the same way. +This chapter teaches you the gateway's transcription surface: how to declare speech models, how the interim and final roles work together, and how to use batch and Realtime transcription. Speech builds on local models, because speech models are provisioned and cached the same way. ## Declare speech models @@ -403,10 +409,10 @@ A profile may select at most one interim and one final STT model. A final model ## Tune push-to-talk capture -Tune capture in the optional `[workshop.stt]` section: +Tune the pipeline in the optional `[stt]` section: ```` -[workshop.stt] +[stt] window_seconds = 15 interval_ms = 500 vocabulary = ["MCP", "GGUF", "Lua"] @@ -414,7 +420,9 @@ vocabulary = ["MCP", "GGUF", "Lua"] The `window_seconds` key sets the seconds of trailing audio transcribed per pass (default 15), and `interval_ms` sets the milliseconds between passes (default 500). Each must be at least 1; a zero value fails startup. The `vocabulary` lists domain terms that bias both transcription workers toward those terms. An empty list disables biasing. A vocabulary that exceeds the model's prompt budget is truncated, and a warning is logged. -## The transcription endpoint +Version 2 accepts only the canonical `[stt]` section. Legacy `[workshop.stt]` input is rejected as an unknown workshop field whether it appears alone or beside `[stt]`, and saved configuration uses only `[stt]`. + +## Batch transcription With the default-on `stt` feature the gateway serves OpenAI-compatible audio transcription at POST /v1/audio/transcriptions. The multipart form accepts `file`, `model`, `language`, `prompt`, `temperature`, `response_format`, and the repeated field `timestamp_granularities[]`. @@ -434,15 +442,17 @@ A recorded take is split into speech segments at silence boundaries. A segment c With a final model configured, completed speech segments are re-transcribed in the background while the take still records, and each segment's text is reported as it finishes. Without a final model, the stop falls back to the interim model. Silent or very short fragments are skipped so the model does not invent text for them. Transcription is pinned to English, and translation is disabled. -## The streaming socket +## Realtime transcription + +The gateway serves authenticated Realtime transcription at `WS /v1/realtime?intent=transcription`. The query is exact: missing, duplicate, malformed, unsupported, or additional parameters are rejected before upgrade. Native clients may omit Origin; browser clients must send an HTTP loopback Origin. -The gateway serves the authenticated streaming speech-to-text WebSocket at `/stt` and its `GET /stt/capability` probe. The desktop application's Workshop listener relays those routes under the same paths, so the webview remains same-origin and never receives the gateway credential. +The server creates a transcription session for the logical model `realtime-transcribe`. Clients may send `session.update`, `input_audio_buffer.append`, `input_audio_buffer.clear`, and `input_audio_buffer.commit`. Audio appends are canonical Base64 containing signed little-endian mono PCM16 at 24 kHz. The gateway preserves an odd trailing byte across appends, continuously resamples to 16 kHz, flushes the resampler on commit, and resets the whole input on clear. -The client drives the socket with the bare text messages `start` and `stop` and binary little-endian f32 PCM audio frames. The wire contract has a `stream` frame announcing each take, `interim` frames carrying committed and tentative transcripts, and a `final` frame with the transcript and frame count. Frames carry a per-connection generation counter, and committed text is append-only across interim frames. +Only null noise reduction and turn detection are accepted. Session updates may change the transcription prompt and negotiate the PromptForge extension `item.input_audio_transcription.hypothesis`. Standard clients receive OpenAI-shaped session, item, transcription delta, completed, failed, and error events. Extension clients also receive revisioned replacement snapshots with the complete transcript and its finalized, agreed, and tentative regions; completion remains authoritative. -The /stt socket refuses cross-site browser connections: the upgrade performs an Origin allowlist check and answers 403. +One connection may have four committed items finalizing concurrently, and the service admits at most eight Realtime sessions. One append decodes to at most 15 MiB, one uncommitted input holds at most 30 seconds of audio, and committed audio must be at least 100 ms. Queue and capacity overloads return explicit errors instead of waiting without limit. -During a take the status bar shows "Listening...", then "Transcribing...", then "Finalizing transcript...", and failures appear as notices. A take that overruns the interim window without a final model is truncated; the warning names the window length and the dropped lead in seconds. +The desktop Workshop exposes the same `/v1/realtime` path on its own origin. Its server authenticates the fixed upstream target and relays payloads without parsing them, so the webview never receives the gateway credential. Switching the active profile provisions and loads the selected speech models. Switching away unloads the engine and releases the model memory. @@ -752,7 +762,7 @@ When no `[tools.web_search]` section is configured, the route answers 404. The r ## The deprecated [workshop] section -The gateway never hosts the workshop: the desktop application embeds the workshop server itself, and the standalone `workshop-server` binary serves the UI for a browser. A boot config carried over from an older version may still declare a `[workshop]` section. The section keeps parsing - an existing config must not fail - and the gateway logs a deprecation warning at startup naming what changed: the section's `bind` and `open_browser` settings are inert, while the `[workshop.stt]` capture tuning still applies to the speech engine. +The gateway never hosts the workshop: the desktop application embeds the workshop server itself, and the standalone `workshop-server` binary serves the UI for a browser. A boot config carried over from an older version may still declare a `[workshop]` section with the inert `bind` and `open_browser` settings, which produce a deprecation warning at startup. Speech pipeline tuning belongs in `[stt]`; legacy `[workshop.stt]` input is rejected as an unknown workshop field whether it appears alone or beside `[stt]`. ## Manage the cache @@ -764,7 +774,7 @@ The gateway restricts the cache root to your own account at startup and refuses ## Status, progress, and metrics -GET /admin/status reports the active profile, the models it exposes, and a config generation that changes when the gateway restarts. GET /admin/profiles lists the profiles in the loaded catalog. +GET /admin/status reports the active profile, the models it exposes, and a config generation that changes when the gateway restarts. With the STT feature it also includes generic `speech` facts: whether speech is configured, whether a complete generation is ready, whether its backend reports GPU acceleration, and the active generation number. A featureless build omits the speech object. GET /admin/profiles lists the profiles in the loaded catalog. GET /admin/progress streams every long-running operation in the process as one server-sent event stream. A fresh subscriber first receives live operations replayed, then every event. Heartbeat comment lines arrive every 15 seconds while idle. diff --git a/guide/promptforge-workshop-guide.md b/guide/promptforge-workshop-guide.md index 9912048d..cdc17022 100644 --- a/guide/promptforge-workshop-guide.md +++ b/guide/promptforge-workshop-guide.md @@ -53,14 +53,16 @@ The Workshop also keeps working when parts of its environment fail. The interfac ## The gateway configuration -The gateway owns its own boot config, `gateway.toml`, and the Workshop never reads it. On the gateway's first run - when no config exists anywhere it searches - the gateway writes a default `gateway.toml` into `%USERPROFILE%\.promptforge\` and prints a message telling you where it wrote the file. It also creates `profiles\default.toml` beside it, and it never overwrites an existing `profiles\default.toml`. The generated config boots the gateway into the `default` profile. +The gateway owns its own boot config, `gateway.toml`, and the Workshop never reads it. On the gateway's first run - when no config exists anywhere it searches - the gateway writes a default `gateway.toml` into `%USERPROFILE%\.promptforge\` and a sibling `gateway.state.toml` selecting the generated `default` profile. The generated catalog, profiles, and global settings all live in that one editable config file. The generated config is a single editable TOML file with a header that invites edits. Two properties of the generated file are worth knowing: - The gateway is secured with a freshly generated random bearer key, so no two installs share a key. - The gateway listens on the loopback address only, on an OS-assigned port. It is not reachable from other machines, and the Workshop learns the port from the connection file the gateway writes. -A `gateway.toml` carried over from an older version may declare a `[workshop]` section. It still parses: the gateway logs a deprecation warning, its `bind` and `open_browser` settings do nothing (the Workshop's server now lives inside the desktop application), and only the `[workshop.stt]` capture tuning still applies. +A `gateway.toml` carried over from an older version may declare a `[workshop]` section with the inert `bind` and `open_browser` settings, which produce a deprecation warning because the Workshop's server now lives inside the desktop application. Speech pipeline tuning belongs in `[stt]`; legacy `[workshop.stt]` input is rejected as an unknown workshop field whether it appears alone or beside `[stt]`. + +Voice uses the same separation. The gateway owns speech models, worker lifecycle, batch transcription, and the generic Realtime endpoint. The Workshop server contributes only an authenticated same-origin relay, while the browser UI owns microphone capture and transcript presentation. At run time the gateway also downloads the pinned voice runtime matched to your machine (CUDA on Windows, Metal on Apple Silicon, CPU on the other supported targets), plus the managed `llama-server`. You make no build-time choices for this. @@ -470,7 +472,7 @@ You can now hold a full conversation, steer it, and recover from anything that i You can type prompts into the chat surface. This chapter teaches you to speak them instead. Dictation uses a push-to-talk microphone button beside the send button, and the transcript lands in the prompt exactly as if you had typed it. If voice is not available on your machine, this chapter also teaches you how to tell and why. -The desktop application keeps the microphone connection same-origin: its Workshop server relays `/stt` and `/stt/capability` to the gateway, which owns the speech models and transcription engine. The gateway credential stays in the server process and is never exposed to the webview. +The desktop application keeps the microphone connection same-origin: its Workshop server relays `/v1/realtime` to the gateway's fixed `/v1/realtime?intent=transcription` target. The relay authenticates upstream but never parses speech payloads or owns speech state. The gateway credential stays in the server process and is never exposed to the webview. ## Dictating a prompt @@ -480,7 +482,7 @@ To dictate into the chat input: 2. Speak your message. 3. Click the microphone button again to stop. The tooltip now reads "Stop recording". -While you speak, you see live transcription as a growing committed prefix plus a tentative tail. When you stop, the assembled final transcript replaces the interim text and focus returns to the input. After you stop, the input stays locked until the final transcript arrives; a slow transcription is allowed up to two minutes. +While you speak, you see one evolving transcript. Each revision replaces the previous hypothesis in the same editor range, so revised phrases do not accumulate. When you stop, the authoritative completion replaces the hypothesis and focus returns to the input. After you stop, the input stays locked until the final transcript arrives; other committed takes may finish independently. Dictation splices the transcript into the current selection, behaving like typing at the cursor. Newlines in the transcript become line breaks. Dictating over a selection replaces the selection outright. Consecutive takes compose, because each take captures the cursor position fresh at record start. You never see stale transcription text from a previous take: takes are numbered per connection, and frames from a superseded take are discarded. @@ -490,16 +492,11 @@ The status bar shows a red recording LED while the microphone is capturing, and ## When the mic does nothing -The mic stays visible and clickable in every state. Dictation is gated on a capability check and on a pending input wait: the application asks the server what dictation can do here and treats any failure of that check as blocked. Clicking the mic while dictation cannot start names the blocker on the status bar instead of silently doing nothing: - -- "Dictation is still checking what this server can do; try again in a moment." -- "Dictation needs a GPU this server doesn't have." -- "No speech models are provisioned in the active profile." -- "The agent isn't asking for input; the mic opens when it does." +The mic stays visible and clickable in every state. Dictation is gated by the agent's pending input wait. Clicking it at another time names the blocker on the status bar: "The agent isn't asking for input; the mic opens when it does." The first eligible click may connect the Realtime session and ask you to try again in a moment; the session then reconnects with bounded backoff after a dropped connection. Failures during dictation are named too. Microphone permission denial or capture failure is named on the status bar. A dropped dictation connection is reported on the status bar, including drops before the final transcript lands. A server error message during a take is shown verbatim on the status bar and ends the take. A browser without microphone, audio, or WebSocket support is told "Dictation is not available in this browser." -Under the hood, the Workshop serves a speech-to-text socket endpoint at `/stt`. Dictation streams your speech to it continuously as mono audio blocks while you talk. Microphone capture applies echo cancellation and noise suppression, and the audio is resampled to 16 kHz before it is sent for transcription. +Under the hood, the Workshop serves a payload-opaque Realtime socket at `/v1/realtime`. Browser capture applies echo cancellation and noise suppression, resamples to 24 kHz, converts samples to signed little-endian PCM16, and sends canonical Base64 audio appends. Stop flushes the capture worklet before committing the input buffer, so the final short block is included. ## Microphone permission on each platform @@ -513,10 +510,10 @@ If microphone setup fails at startup, you can keep working in the application an ## Voice configuration -Voice input comes pre-tuned with a 15-second transcription window and a 500 ms interval, set in the `[workshop.stt]` section of the boot config: +Voice input comes pre-tuned with a 15-second transcription window and a 500 ms interval, set in the `[stt]` section of the gateway boot config: ```` -[workshop.stt] +[stt] window_seconds = 15 interval_ms = 500 ```` @@ -527,6 +524,8 @@ You can add a `vocabulary` list of domain terms to bias recognition: vocabulary = ["MCP", "GGUF", "Lua"] ```` +Version 2 accepts only the canonical `[stt]` section. Legacy `[workshop.stt]` input is rejected as an unknown workshop field whether it appears alone or beside `[stt]`, and the gateway saves only `[stt]`. + First run provisions two recommended speech-to-text models: `whisper-base-en` for interim results and `whisper-small-en` for final results. They download from Hugging Face with pinned sha256 checksums and stated VRAM requirements of 1.0 GB and 2.0 GB. The generated configuration boots the gateway into a profile named `default` that activates both provisioned whisper models. You can now speak or type your prompts. The next chapter teaches you to give the agent files to work on by granting folders to the workspace. diff --git a/guide/src/gateway/01-install-and-run.md b/guide/src/gateway/01-install-and-run.md index c5aad568..d257a069 100644 --- a/guide/src/gateway/01-install-and-run.md +++ b/guide/src/gateway/01-install-and-run.md @@ -18,15 +18,15 @@ promptforge-gateway --version ## Start the gateway -Start the gateway with one subcommand that names a config file and a profile: +Start the gateway by naming a config file and a profile: ```` -promptforge-gateway serve gateway.toml --profile main +promptforge-gateway --config gateway.toml --profile main ```` -The first argument is the path to the config file. The `--profile` flag names the profile to activate. The gateway always starts from one config file and one active profile. +The `--config` flag gives the path to the config file. The `--profile` flag names the profile to activate. The gateway always starts from one config file and one active profile. -You can supply both values through environment variables instead of command-line arguments. The config path comes from the positional argument or from `PROMPTFORGE_GATEWAY_CONFIG`; the command line wins when both are set. The profile comes from `--profile`, then `PROMPTFORGE_PROFILE`, then the sibling state file the gateway keeps beside the config. +You can supply both values through environment variables instead of command-line arguments. The config path comes from `--config` or from `PROMPTFORGE_GATEWAY_CONFIG`; the flag wins when both are set. The profile comes from `--profile`, then `PROMPTFORGE_PROFILE`, then the sibling state file the gateway keeps beside the config. You can also start the gateway with no config file at all. When no `gateway.toml` exists beside the executable, in the working directory, or in the user profile's `.promptforge` directory, the first run writes a default config there - loopback-only on an OS-assigned port, with a fresh random bearer key and `trust_loopback = true` so callers on the same machine need no key - and boots from it. The generated file notes the caveat beside that line: on a shared machine any other OS account can then use the gateway, and `trust_loopback = false` requires the key from everyone. The generated config selects a profile named `default`, so a bare first boot needs no flags. @@ -71,7 +71,7 @@ Build-time feature flags decide which capabilities exist in the binary. The flag On Linux the release archive contains a sample systemd unit. The unit runs the gateway as a service with a fixed config path and profile, and restarts it automatically on failure: ```` -ExecStart=/usr/local/bin/promptforge-gateway serve /etc/promptforge/gateway.toml --profile main +ExecStart=/usr/local/bin/promptforge-gateway --config /etc/promptforge/gateway.toml --profile main Restart=on-failure RestartSec=5 ```` @@ -80,9 +80,15 @@ The gateway holds vendor credentials, so run it as a dedicated unprivileged user ## Watch the logs +A serving gateway logs to `gateway.log` in the `logs` directory under the state directory (`~/.promptforge/logs` on a default install) and mirrors the same stream to stdout. Startup rotates the previous run's log aside - `gateway.log` becomes `gateway.log.1` - and keeps five previous runs, deleting the oldest. Every record crosses a redaction pass before it reaches disk: bearer tokens, authorization and cookie header values, and `api_key` assignments are masked. The log location is never configurable, so a config failure still has somewhere to report itself. + Control log verbosity through the standard `RUST_LOG` environment filter. The speech library logs at warn level by default, so it stays quiet unless you ask for more. -Startup failures appear on stderr with the full cause chain: one `error:` line followed by one `caused by:` line per cause. Once the gateway is serving, the log shows the bound address. If you configured port 0, the log reports the real bound port. +Startup failures appear on stderr with the full cause chain: one `error:` line followed by one `caused by:` line per cause, and the same chain lands in the log file. Once the gateway is serving, the log shows the bound address. If you configured port 0, the log reports the real bound port. + +## Inspect a failed run + +When a gateway run fails before it can serve, `promptforge-gateway diagnostics` finds the evidence without any config knowledge. It prints a read-only JSON report: the state directory, the resolved config path and whether it exists, the current and retained log paths and which exist, the connection file, whether a gateway is running, and the version. It never serves, rotates a log, parses a config, or mutates the state directory, and it never prints secrets - no bearer key, environment value, config content, or log content. The generated config points at it in a comment. ## Stop the gateway diff --git a/guide/src/gateway/05-speech.md b/guide/src/gateway/05-speech.md index 722c9f6c..0cb00380 100644 --- a/guide/src/gateway/05-speech.md +++ b/guide/src/gateway/05-speech.md @@ -1,6 +1,6 @@ # Speech-to-Text -This chapter teaches you the gateway's transcription surface: how to declare speech models, how the interim and final roles work together, and what the transcription endpoint serves. Speech builds on local models, because speech models are provisioned and cached the same way. +This chapter teaches you the gateway's transcription surface: how to declare speech models, how the interim and final roles work together, and how to use batch and Realtime transcription. Speech builds on local models, because speech models are provisioned and cached the same way. ## Declare speech models @@ -21,10 +21,10 @@ A profile may select at most one interim and one final STT model. A final model ## Tune push-to-talk capture -Tune capture in the optional `[workshop.stt]` section: +Tune the pipeline in the optional `[stt]` section: ```` -[workshop.stt] +[stt] window_seconds = 15 interval_ms = 500 vocabulary = ["MCP", "GGUF", "Lua"] @@ -32,7 +32,9 @@ vocabulary = ["MCP", "GGUF", "Lua"] The `window_seconds` key sets the seconds of trailing audio transcribed per pass (default 15), and `interval_ms` sets the milliseconds between passes (default 500). Each must be at least 1; a zero value fails startup. The `vocabulary` lists domain terms that bias both transcription workers toward those terms. An empty list disables biasing. A vocabulary that exceeds the model's prompt budget is truncated, and a warning is logged. -## The transcription endpoint +Version 2 accepts only the canonical `[stt]` section. Legacy `[workshop.stt]` input is rejected as an unknown workshop field whether it appears alone or beside `[stt]`, and saved configuration uses only `[stt]`. + +## Batch transcription With the default-on `stt` feature the gateway serves OpenAI-compatible audio transcription at POST /v1/audio/transcriptions. The multipart form accepts `file`, `model`, `language`, `prompt`, `temperature`, `response_format`, and the repeated field `timestamp_granularities[]`. @@ -52,15 +54,17 @@ A recorded take is split into speech segments at silence boundaries. A segment c With a final model configured, completed speech segments are re-transcribed in the background while the take still records, and each segment's text is reported as it finishes. Without a final model, the stop falls back to the interim model. Silent or very short fragments are skipped so the model does not invent text for them. Transcription is pinned to English, and translation is disabled. -## The streaming socket +## Realtime transcription + +The gateway serves authenticated Realtime transcription at `WS /v1/realtime?intent=transcription`. The query is exact: missing, duplicate, malformed, unsupported, or additional parameters are rejected before upgrade. Native clients may omit Origin; browser clients must send an HTTP loopback Origin. -The gateway serves the authenticated streaming speech-to-text WebSocket at `/stt` and its `GET /stt/capability` probe. The desktop application's Workshop listener relays those routes under the same paths, so the webview remains same-origin and never receives the gateway credential. +The server creates a transcription session for the logical model `realtime-transcribe`. Clients may send `session.update`, `input_audio_buffer.append`, `input_audio_buffer.clear`, and `input_audio_buffer.commit`. Audio appends are canonical Base64 containing signed little-endian mono PCM16 at 24 kHz. The gateway preserves an odd trailing byte across appends, continuously resamples to 16 kHz, flushes the resampler on commit, and resets the whole input on clear. -The client drives the socket with the bare text messages `start` and `stop` and binary little-endian f32 PCM audio frames. The wire contract has a `stream` frame announcing each take, `interim` frames carrying committed and tentative transcripts, and a `final` frame with the transcript and frame count. Frames carry a per-connection generation counter, and committed text is append-only across interim frames. +Only null noise reduction and turn detection are accepted. Session updates may change the transcription prompt and negotiate the PromptForge extension `item.input_audio_transcription.hypothesis`. Standard clients receive OpenAI-shaped session, item, transcription delta, completed, failed, and error events. Extension clients also receive revisioned replacement snapshots with the complete transcript and its finalized, agreed, and tentative regions; completion remains authoritative. -The /stt socket refuses cross-site browser connections: the upgrade performs an Origin allowlist check and answers 403. +One connection may have four committed items finalizing concurrently, and the service admits at most eight Realtime sessions. One append decodes to at most 15 MiB, one uncommitted input holds at most 30 seconds of audio, and committed audio must be at least 100 ms. Queue and capacity overloads return explicit errors instead of waiting without limit. -During a take the status bar shows "Listening...", then "Transcribing...", then "Finalizing transcript...", and failures appear as notices. A take that overruns the interim window without a final model is truncated; the warning names the window length and the dropped lead in seconds. +The desktop Workshop exposes the same `/v1/realtime` path on its own origin. Its server authenticates the fixed upstream target and relays payloads without parsing them, so the webview never receives the gateway credential. Switching the active profile provisions and loads the selected speech models. Switching away unloads the engine and releases the model memory. diff --git a/guide/src/gateway/10-serving-and-observing.md b/guide/src/gateway/10-serving-and-observing.md index 6f909b3f..96b6c56a 100644 --- a/guide/src/gateway/10-serving-and-observing.md +++ b/guide/src/gateway/10-serving-and-observing.md @@ -26,7 +26,7 @@ When no `[tools.web_search]` section is configured, the route answers 404. The r ## The deprecated [workshop] section -The gateway never hosts the workshop: the desktop application embeds the workshop server itself, and the standalone `workshop-server` binary serves the UI for a browser. A boot config carried over from an older version may still declare a `[workshop]` section. The section keeps parsing - an existing config must not fail - and the gateway logs a deprecation warning at startup naming what changed: the section's `bind` and `open_browser` settings are inert, while the `[workshop.stt]` capture tuning still applies to the speech engine. +The gateway never hosts the workshop: the desktop application embeds the workshop server itself, and the standalone `workshop-server` binary serves the UI for a browser. A boot config carried over from an older version may still declare a `[workshop]` section with the inert `bind` and `open_browser` settings, which produce a deprecation warning at startup. Speech pipeline tuning belongs in `[stt]`; legacy `[workshop.stt]` input is rejected as an unknown workshop field whether it appears alone or beside `[stt]`. ## Manage the cache @@ -38,7 +38,7 @@ The gateway restricts the cache root to your own account at startup and refuses ## Status, progress, and metrics -GET /admin/status reports the active profile, the models it exposes, and a config generation that changes when the gateway restarts. GET /admin/profiles lists the profiles in the loaded catalog. +GET /admin/status reports the active profile, the models it exposes, and a config generation that changes when the gateway restarts. With the STT feature it also includes generic `speech` facts: whether speech is configured, whether a complete generation is ready, whether its backend reports GPU acceleration, and the active generation number. A featureless build omits the speech object. GET /admin/profiles lists the profiles in the loaded catalog. GET /admin/progress streams every long-running operation in the process as one server-sent event stream. A fresh subscriber first receives live operations replayed, then every event. Heartbeat comment lines arrive every 15 seconds while idle. diff --git a/guide/src/workshop/01-application.md b/guide/src/workshop/01-application.md index 725a1c66..0aebdba6 100644 --- a/guide/src/workshop/01-application.md +++ b/guide/src/workshop/01-application.md @@ -49,14 +49,16 @@ The Workshop also keeps working when parts of its environment fail. The interfac ## The gateway configuration -The gateway owns its own boot config, `gateway.toml`, and the Workshop never reads it. On the gateway's first run - when no config exists anywhere it searches - the gateway writes a default `gateway.toml` into `%USERPROFILE%\.promptforge\` and prints a message telling you where it wrote the file. It also creates `profiles\default.toml` beside it, and it never overwrites an existing `profiles\default.toml`. The generated config boots the gateway into the `default` profile. +The gateway owns its own boot config, `gateway.toml`, and the Workshop never reads it. On the gateway's first run - when no config exists anywhere it searches - the gateway writes a default `gateway.toml` into `%USERPROFILE%\.promptforge\` and a sibling `gateway.state.toml` selecting the generated `default` profile. The generated catalog, profiles, and global settings all live in that one editable config file. The generated config is a single editable TOML file with a header that invites edits. Two properties of the generated file are worth knowing: - The gateway is secured with a freshly generated random bearer key, so no two installs share a key. - The gateway listens on the loopback address only, on an OS-assigned port. It is not reachable from other machines, and the Workshop learns the port from the connection file the gateway writes. -A `gateway.toml` carried over from an older version may declare a `[workshop]` section. It still parses: the gateway logs a deprecation warning, its `bind` and `open_browser` settings do nothing (the Workshop's server now lives inside the desktop application), and only the `[workshop.stt]` capture tuning still applies. +A `gateway.toml` carried over from an older version may declare a `[workshop]` section with the inert `bind` and `open_browser` settings, which produce a deprecation warning because the Workshop's server now lives inside the desktop application. Speech pipeline tuning belongs in `[stt]`; legacy `[workshop.stt]` input is rejected as an unknown workshop field whether it appears alone or beside `[stt]`. + +Voice uses the same separation. The gateway owns speech models, worker lifecycle, batch transcription, and the generic Realtime endpoint. The Workshop server contributes only an authenticated same-origin relay, while the browser UI owns microphone capture and transcript presentation. At run time the gateway also downloads the pinned voice runtime matched to your machine (CUDA on Windows, Metal on Apple Silicon, CPU on the other supported targets), plus the managed `llama-server`. You make no build-time choices for this. diff --git a/guide/src/workshop/07-voice.md b/guide/src/workshop/07-voice.md index 4cbee28a..a0c01a7a 100644 --- a/guide/src/workshop/07-voice.md +++ b/guide/src/workshop/07-voice.md @@ -2,7 +2,7 @@ You can type prompts into the chat surface. This chapter teaches you to speak them instead. Dictation uses a push-to-talk microphone button beside the send button, and the transcript lands in the prompt exactly as if you had typed it. If voice is not available on your machine, this chapter also teaches you how to tell and why. -The desktop application keeps the microphone connection same-origin: its Workshop server relays `/stt` and `/stt/capability` to the gateway, which owns the speech models and transcription engine. The gateway credential stays in the server process and is never exposed to the webview. +The desktop application keeps the microphone connection same-origin: its Workshop server relays `/v1/realtime` to the gateway's fixed `/v1/realtime?intent=transcription` target. The relay authenticates upstream but never parses speech payloads or owns speech state. The gateway credential stays in the server process and is never exposed to the webview. ## Dictating a prompt @@ -12,7 +12,7 @@ To dictate into the chat input: 2. Speak your message. 3. Click the microphone button again to stop. The tooltip now reads "Stop recording". -While you speak, you see live transcription as a growing committed prefix plus a tentative tail. When you stop, the assembled final transcript replaces the interim text and focus returns to the input. After you stop, the input stays locked until the final transcript arrives; a slow transcription is allowed up to two minutes. +While you speak, you see one evolving transcript. Each revision replaces the previous hypothesis in the same editor range, so revised phrases do not accumulate. When you stop, the authoritative completion replaces the hypothesis and focus returns to the input. After you stop, the input stays locked until the final transcript arrives; other committed takes may finish independently. Dictation splices the transcript into the current selection, behaving like typing at the cursor. Newlines in the transcript become line breaks. Dictating over a selection replaces the selection outright. Consecutive takes compose, because each take captures the cursor position fresh at record start. You never see stale transcription text from a previous take: takes are numbered per connection, and frames from a superseded take are discarded. @@ -22,16 +22,11 @@ The status bar shows a red recording LED while the microphone is capturing, and ## When the mic does nothing -The mic stays visible and clickable in every state. Dictation is gated on a capability check and on a pending input wait: the application asks the server what dictation can do here and treats any failure of that check as blocked. Clicking the mic while dictation cannot start names the blocker on the status bar instead of silently doing nothing: - -- "Dictation is still checking what this server can do; try again in a moment." -- "Dictation needs a GPU this server doesn't have." -- "No speech models are provisioned in the active profile." -- "The agent isn't asking for input; the mic opens when it does." +The mic stays visible and clickable in every state. Dictation is gated by the agent's pending input wait. Clicking it at another time names the blocker on the status bar: "The agent isn't asking for input; the mic opens when it does." The first eligible click may connect the Realtime session and ask you to try again in a moment; the session then reconnects with bounded backoff after a dropped connection. Failures during dictation are named too. Microphone permission denial or capture failure is named on the status bar. A dropped dictation connection is reported on the status bar, including drops before the final transcript lands. A server error message during a take is shown verbatim on the status bar and ends the take. A browser without microphone, audio, or WebSocket support is told "Dictation is not available in this browser." -Under the hood, the Workshop serves a speech-to-text socket endpoint at `/stt`. Dictation streams your speech to it continuously as mono audio blocks while you talk. Microphone capture applies echo cancellation and noise suppression, and the audio is resampled to 16 kHz before it is sent for transcription. +Under the hood, the Workshop serves a payload-opaque Realtime socket at `/v1/realtime`. Browser capture applies echo cancellation and noise suppression, resamples to 24 kHz, converts samples to signed little-endian PCM16, and sends canonical Base64 audio appends. Stop flushes the capture worklet before committing the input buffer, so the final short block is included. ## Microphone permission on each platform @@ -45,10 +40,10 @@ If microphone setup fails at startup, you can keep working in the application an ## Voice configuration -Voice input comes pre-tuned with a 15-second transcription window and a 500 ms interval, set in the `[workshop.stt]` section of the boot config: +Voice input comes pre-tuned with a 15-second transcription window and a 500 ms interval, set in the `[stt]` section of the gateway boot config: ```` -[workshop.stt] +[stt] window_seconds = 15 interval_ms = 500 ```` @@ -59,6 +54,8 @@ You can add a `vocabulary` list of domain terms to bias recognition: vocabulary = ["MCP", "GGUF", "Lua"] ```` +Version 2 accepts only the canonical `[stt]` section. Legacy `[workshop.stt]` input is rejected as an unknown workshop field whether it appears alone or beside `[stt]`, and the gateway saves only `[stt]`. + First run provisions two recommended speech-to-text models: `whisper-base-en` for interim results and `whisper-small-en` for final results. They download from Hugging Face with pinned sha256 checksums and stated VRAM requirements of 1.0 GB and 2.0 GB. The generated configuration boots the gateway into a profile named `default` that activates both provisioned whisper models. You can now speak or type your prompts. The next chapter teaches you to give the agent files to work on by granting folders to the workspace. diff --git a/tools/check-integration-test-ceilings.mjs b/tools/check-integration-test-ceilings.mjs new file mode 100644 index 00000000..7a8e8c80 --- /dev/null +++ b/tools/check-integration-test-ceilings.mjs @@ -0,0 +1,616 @@ +import { + readdirSync, + readFileSync, + statSync, +} from "node:fs"; +import { dirname, join, posix, relative, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +export const REQUIRED_SUITES = Object.freeze([ + "crates/gateway/tests/it/realtime_stt", + "crates/workshop-server/tests/it/chat_gate", + "crates/workshop-server/tests/it/realtime_relay", +]); + +const SUPPORTED_TEST_ATTRIBUTES = new Set(["test", "tokio::test"]); +const ITEM_KEYWORDS = new Set([ + "const", + "enum", + "fn", + "impl", + "mod", + "static", + "struct", + "trait", + "type", + "union", + "use", +]); + +function fail(message) { + throw new Error(message); +} + +function isObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function repoPath(value) { + return value.replaceAll("\\", "/"); +} + +function requireNormalizedRepoPath(value, label) { + if (typeof value !== "string" || value.length === 0) { + fail(`${label} must be a non-empty repository-relative path`); + } + if (value !== repoPath(value)) { + fail(`${label} must use normalized repository separators: ${value}`); + } + const parts = value.split("/"); + if ( + value.startsWith("/") || + /^[A-Za-z]:/.test(value) || + parts.some((part) => part.length === 0 || part === "." || part === "..") + ) { + fail(`${label} must be a normalized repository-relative path: ${value}`); + } +} + +export function physicalLineCount(source) { + if (source.length === 0) { + return 0; + } + const lines = source.split(/\r\n|\n|\r/); + if (/(?:\r\n|\n|\r)$/.test(source)) { + lines.pop(); + } + return lines.length; +} + +function rawStringAt(source, start) { + let cursor = start; + if (source[cursor] === "b") { + cursor += 1; + } + if (source[cursor] !== "r") { + return undefined; + } + cursor += 1; + let hashes = 0; + while (source[cursor] === "#") { + hashes += 1; + cursor += 1; + } + if (source[cursor] !== '"') { + return undefined; + } + const contentStart = cursor + 1; + const closing = `"${"#".repeat(hashes)}`; + const closingStart = source.indexOf(closing, contentStart); + if (closingStart < 0) { + fail("unterminated Rust raw string"); + } + return { + end: closingStart + closing.length, + value: source.slice(contentStart, closingStart), + }; +} + +function quotedStringAt(source, start) { + const quote = source[start] === "b" ? start + 1 : start; + if (source[quote] !== '"') { + return undefined; + } + let value = ""; + for (let cursor = quote + 1; cursor < source.length; cursor += 1) { + const character = source[cursor]; + if (character === '"') { + return { end: cursor + 1, value }; + } + if (character !== "\\") { + value += character; + continue; + } + cursor += 1; + const escaped = source[cursor]; + const simple = { + 0: "\0", + '"': '"', + "'": "'", + "\\": "\\", + n: "\n", + r: "\r", + t: "\t", + }; + if (Object.hasOwn(simple, escaped)) { + value += simple[escaped]; + } else if (escaped === "x") { + const digits = source.slice(cursor + 1, cursor + 3); + if (!/^[0-9A-Fa-f]{2}$/.test(digits)) { + fail("invalid Rust hexadecimal string escape"); + } + value += String.fromCharCode(Number.parseInt(digits, 16)); + cursor += 2; + } else if (escaped === "u" && source[cursor + 1] === "{") { + const close = source.indexOf("}", cursor + 2); + const digits = source.slice(cursor + 2, close); + if (close < 0 || !/^[0-9A-Fa-f_]+$/.test(digits)) { + fail("invalid Rust Unicode string escape"); + } + value += String.fromCodePoint(Number.parseInt(digits.replaceAll("_", ""), 16)); + cursor = close; + } else if (escaped === "\n" || escaped === "\r") { + if (escaped === "\r" && source[cursor + 1] === "\n") { + cursor += 1; + } + while (/\s/.test(source[cursor + 1] ?? "")) { + cursor += 1; + } + } else { + fail(`unsupported Rust string escape: \\${escaped}`); + } + } + fail("unterminated Rust string"); +} + +function rustTokens(source) { + const tokens = []; + for (let cursor = 0; cursor < source.length; ) { + if (/\s/.test(source[cursor])) { + cursor += 1; + continue; + } + if (source.startsWith("//", cursor)) { + const newline = source.indexOf("\n", cursor + 2); + cursor = newline < 0 ? source.length : newline + 1; + continue; + } + if (source.startsWith("/*", cursor)) { + let depth = 1; + cursor += 2; + while (cursor < source.length && depth > 0) { + if (source.startsWith("/*", cursor)) { + depth += 1; + cursor += 2; + } else if (source.startsWith("*/", cursor)) { + depth -= 1; + cursor += 2; + } else { + cursor += 1; + } + } + if (depth !== 0) { + fail("unterminated Rust block comment"); + } + continue; + } + + const rawString = rawStringAt(source, cursor); + if (rawString !== undefined) { + tokens.push({ kind: "string", value: rawString.value }); + cursor = rawString.end; + continue; + } + const quotedString = quotedStringAt(source, cursor); + if (quotedString !== undefined) { + tokens.push({ kind: "string", value: quotedString.value }); + cursor = quotedString.end; + continue; + } + if ( + source[cursor] === "'" && + (source[cursor + 2] === "'" || + (source[cursor + 1] === "\\" && source[cursor + 3] === "'")) + ) { + cursor += source[cursor + 1] === "\\" ? 4 : 3; + continue; + } + if (/[A-Za-z_]/.test(source[cursor])) { + let end = cursor + 1; + while (/[A-Za-z0-9_]/.test(source[end] ?? "")) { + end += 1; + } + tokens.push({ kind: "identifier", value: source.slice(cursor, end) }); + cursor = end; + continue; + } + if (source.startsWith("::", cursor)) { + tokens.push({ kind: "punctuation", value: "::" }); + cursor += 2; + continue; + } + tokens.push({ kind: "punctuation", value: source[cursor] }); + cursor += 1; + } + return tokens; +} + +function matchingDelimiter(tokens, openIndex) { + const pairs = { "(": ")", "[": "]", "{": "}" }; + const stack = [pairs[tokens[openIndex]?.value]]; + if (stack[0] === undefined) { + fail("expected an opening Rust delimiter"); + } + for (let cursor = openIndex + 1; cursor < tokens.length; cursor += 1) { + const value = tokens[cursor].value; + if (Object.hasOwn(pairs, value)) { + stack.push(pairs[value]); + } else if (value === stack.at(-1)) { + stack.pop(); + if (stack.length === 0) { + return cursor; + } + } + } + fail("unterminated Rust delimiter"); +} + +function attributeAt(tokens, start) { + if (tokens[start]?.value !== "#" || tokens[start + 1]?.value !== "[") { + return undefined; + } + const end = matchingDelimiter(tokens, start + 1); + const path = []; + for (let cursor = start + 2; cursor < end; cursor += 1) { + const token = tokens[cursor]; + if (token.kind === "identifier" || token.value === "::") { + path.push(token.value); + } else { + break; + } + } + return { end, path: path.join("") }; +} + +function testAttributeKind(path) { + if (SUPPORTED_TEST_ATTRIBUTES.has(path)) { + return "supported"; + } + if (path === "test" || path.endsWith("::test")) { + return "unsupported"; + } + return undefined; +} + +function includeAt(tokens, start) { + if ( + tokens[start]?.value !== "include" || + tokens[start + 1]?.value !== "!" || + !["(", "[", "{"].includes(tokens[start + 2]?.value) + ) { + return undefined; + } + const end = matchingDelimiter(tokens, start + 2); + const macroArguments = tokens.slice(start + 3, end); + if (macroArguments.length !== 1 || macroArguments[0].kind !== "string") { + fail("include! in a manifested integration suite must use one string literal"); + } + return { end, path: macroArguments[0].value }; +} + +function analyzeRust(source, label) { + const tokens = rustTokens(source); + const includes = []; + let depth = 0; + let pendingAttributes = []; + let tests = 0; + + for (let cursor = 0; cursor < tokens.length; cursor += 1) { + const attribute = attributeAt(tokens, cursor); + if (attribute !== undefined) { + const kind = testAttributeKind(attribute.path); + if (depth > 0 && (kind !== undefined || attribute.path === "cfg_attr")) { + fail(`macro-generated test is unsupported in ${label}`); + } + if (depth === 0) { + pendingAttributes.push(attribute); + } + cursor = attribute.end; + continue; + } + + const token = tokens[cursor]; + if (token.value === "{") { + if (pendingAttributes.some((entry) => testAttributeKind(entry.path))) { + fail(`test attribute does not annotate a free function in ${label}`); + } + pendingAttributes = []; + depth += 1; + continue; + } + if (token.value === "}") { + pendingAttributes = []; + depth -= 1; + if (depth < 0) { + fail(`unbalanced Rust delimiter in ${label}`); + } + continue; + } + if (depth > 0) { + continue; + } + + const include = includeAt(tokens, cursor); + if (include !== undefined) { + if ( + pendingAttributes.some( + (entry) => entry.path === "cfg" || entry.path === "cfg_attr", + ) + ) { + fail(`cfg-gated include! is unsupported in ${label}`); + } + includes.push(include.path); + pendingAttributes = []; + cursor = include.end; + continue; + } + if ( + token.kind === "identifier" && + tokens[cursor + 1]?.value === "!" && + token.value !== "include" + ) { + fail(`macro-generated test is unsupported in ${label}: ${token.value}!`); + } + + if (token.value === "fn" && pendingAttributes.length > 0) { + const testAttributes = pendingAttributes.filter( + (entry) => testAttributeKind(entry.path) !== undefined, + ); + if ( + testAttributes.some( + (entry) => testAttributeKind(entry.path) === "unsupported", + ) + ) { + fail(`unsupported Rust test attribute in ${label}`); + } + if ( + pendingAttributes.some( + (entry) => entry.path === "cfg" || entry.path === "cfg_attr", + ) && + (testAttributes.length > 0 || + pendingAttributes.some((entry) => entry.path === "cfg_attr")) + ) { + fail(`cfg-gated test is unsupported in ${label}`); + } + if (testAttributes.length > 1) { + fail(`multiple Rust test attributes annotate one function in ${label}`); + } + tests += testAttributes.length; + pendingAttributes = []; + continue; + } + if (ITEM_KEYWORDS.has(token.value)) { + if (pendingAttributes.some((entry) => testAttributeKind(entry.path))) { + fail(`test attribute does not annotate a free function in ${label}`); + } + pendingAttributes = []; + } + } + if (depth !== 0) { + fail(`unbalanced Rust delimiter in ${label}`); + } + return { includes, tests }; +} + +function discoveredRustFiles(root, suitePath) { + const suiteRoot = join(root, ...suitePath.split("/")); + const files = []; + + function visit(directory) { + let entries; + try { + entries = readdirSync(directory, { withFileTypes: true }); + } catch (error) { + if (error?.code === "ENOENT") { + fail(`missing integration test suite directory: ${suitePath}`); + } + throw error; + } + for (const entry of entries) { + const entryPath = join(directory, entry.name); + if (entry.isDirectory()) { + visit(entryPath); + } else if (entry.name.endsWith(".rs")) { + files.push(repoPath(relative(suiteRoot, entryPath))); + } + } + } + + visit(suiteRoot); + return files.sort(); +} + +function requireExactSuites(suites, requiredSuites) { + const actual = Object.keys(suites).sort(); + const expected = [...requiredSuites].sort(); + const missing = expected.filter((suite) => !actual.includes(suite)); + const extra = actual.filter((suite) => !expected.includes(suite)); + if (missing.length > 0 || extra.length > 0) { + fail( + `integration ceiling manifest suite coverage differs: missing ${JSON.stringify(missing)}, extra ${JSON.stringify(extra)}`, + ); + } +} + +export function checkIntegrationTestCeilings( + root, + manifest, + { requiredSuites = REQUIRED_SUITES } = {}, +) { + if (!isObject(manifest) || manifest.version !== 1 || !isObject(manifest.suites)) { + fail("integration ceiling manifest must be a version 1 object with suites"); + } + if ( + !Array.isArray(requiredSuites) || + requiredSuites.length === 0 || + requiredSuites.some((suite) => typeof suite !== "string") || + new Set(requiredSuites).size !== requiredSuites.length + ) { + fail("required integration suites must be a non-empty array of unique paths"); + } + requireExactSuites(manifest.suites, requiredSuites); + + const results = []; + for (const [suitePath, suite] of Object.entries(manifest.suites)) { + requireNormalizedRepoPath(suitePath, "suite path"); + if ( + !isObject(suite) || + !Number.isInteger(suite.testTotal) || + suite.testTotal < 0 || + !isObject(suite.entry) || + typeof suite.entry.path !== "string" || + !Number.isInteger(suite.entry.ceiling) || + suite.entry.ceiling < 1 || + !isObject(suite.files) || + Object.keys(suite.files).length === 0 + ) { + fail( + `${suitePath} must declare an entry, non-negative testTotal, and non-empty files`, + ); + } + requireNormalizedRepoPath(suite.entry.path, `${suitePath} entry path`); + const expectedEntryPath = `${suitePath}.rs`; + if (suite.entry.path !== expectedEntryPath) { + fail(`${suitePath} entry path must be ${expectedEntryPath}`); + } + + const entryPath = join(root, ...suite.entry.path.split("/")); + let entrySource; + try { + if (!statSync(entryPath).isFile()) { + fail(`missing suite entry module: ${suite.entry.path}`); + } + entrySource = readFileSync(entryPath, "utf8"); + } catch (error) { + if (error?.code === "ENOENT") { + fail(`missing suite entry module: ${suite.entry.path}`); + } + throw error; + } + const entryLines = physicalLineCount(entrySource); + if (entryLines > suite.entry.ceiling) { + fail( + `${suite.entry.path} has ${entryLines} physical lines, ceiling is ${suite.entry.ceiling}`, + ); + } + const entryAnalysis = analyzeRust(entrySource, suite.entry.path); + + const expectedFiles = Object.keys(suite.files).sort(); + const expectedIncludes = expectedFiles.map((file) => + posix.relative( + posix.dirname(suite.entry.path), + posix.join(suitePath, file), + ), + ); + const includeCounts = new Map(); + for (const included of entryAnalysis.includes) { + includeCounts.set(included, (includeCounts.get(included) ?? 0) + 1); + } + for (const expectedInclude of expectedIncludes) { + const count = includeCounts.get(expectedInclude) ?? 0; + if (count > 1) { + fail( + `${suitePath} suite include must appear exactly once: ${expectedInclude}, found ${count}`, + ); + } + } + const missingIncludes = expectedIncludes.filter( + (included) => !includeCounts.has(included), + ); + const extraIncludes = [...includeCounts.keys()] + .filter((included) => !expectedIncludes.includes(included)) + .sort(); + if (missingIncludes.length > 0 || extraIncludes.length > 0) { + fail( + `${suitePath} suite include coverage differs: missing ${JSON.stringify(missingIncludes)}, extra ${JSON.stringify(extraIncludes)}`, + ); + } + + let actualTestTotal = entryAnalysis.tests; + for (const file of expectedFiles) { + requireNormalizedRepoPath(file, `${suitePath} file path`); + if (!file.endsWith(".rs")) { + fail(`${suitePath} manifest entry is not a Rust file: ${file}`); + } + const ceiling = suite.files[file]; + if (!Number.isInteger(ceiling) || ceiling < 1) { + fail(`${suitePath}/${file} ceiling must be a positive integer`); + } + + const filePath = join(root, ...suitePath.split("/"), ...file.split("/")); + let source; + try { + if (!statSync(filePath).isFile()) { + fail(`missing manifested integration test file: ${suitePath}/${file}`); + } + source = readFileSync(filePath, "utf8"); + } catch (error) { + if (error?.code === "ENOENT") { + fail(`missing manifested integration test file: ${suitePath}/${file}`); + } + throw error; + } + + const lines = physicalLineCount(source); + if (lines > ceiling) { + fail( + `${suitePath}/${file} has ${lines} physical lines, ceiling is ${ceiling}`, + ); + } + const analysis = analyzeRust(source, `${suitePath}/${file}`); + if (analysis.includes.length > 0) { + fail(`${suitePath}/${file} must not contain nested include! topology`); + } + actualTestTotal += analysis.tests; + } + + const actualFiles = discoveredRustFiles(root, suitePath); + const missing = expectedFiles.filter((file) => !actualFiles.includes(file)); + if (missing.length > 0) { + fail( + `missing manifested integration test file: ${suitePath}/${missing[0]}`, + ); + } + const extra = actualFiles.filter((file) => !expectedFiles.includes(file)); + if (extra.length > 0) { + fail(`unmanifested integration test file: ${suitePath}/${extra[0]}`); + } + if (actualTestTotal !== suite.testTotal) { + fail( + `${suitePath} has ${actualTestTotal} tests, expected exactly ${suite.testTotal}`, + ); + } + + results.push({ + files: actualFiles.length + 1, + path: suitePath, + tests: actualTestTotal, + }); + } + return results; +} + +function main() { + const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + const manifestPath = join(root, "tools", "integration-test-ceilings.json"); + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + const results = checkIntegrationTestCeilings(root, manifest, { + requiredSuites: REQUIRED_SUITES, + }); + for (const result of results) { + console.log(`${result.path}: ${result.files} files, ${result.tests} tests`); + } +} + +const invokedPath = + process.argv[1] === undefined + ? undefined + : pathToFileURL(resolve(process.argv[1])).href; +if (invokedPath === import.meta.url) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} diff --git a/tools/check-integration-test-ceilings.test.mjs b/tools/check-integration-test-ceilings.test.mjs new file mode 100644 index 00000000..d76e7de2 --- /dev/null +++ b/tools/check-integration-test-ceilings.test.mjs @@ -0,0 +1,379 @@ +import assert from "node:assert/strict"; +import { + mkdtempSync, + mkdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + checkIntegrationTestCeilings, + physicalLineCount, + repoPath, +} from "./check-integration-test-ceilings.mjs"; + +function fixture(files, suite = {}, entrySource = 'include!("split/case.rs");\n') { + const root = mkdtempSync(join(tmpdir(), "promptforge-integration-ceilings-")); + const suitePath = "crates/demo/tests/it/split"; + const entryPath = "crates/demo/tests/it/split.rs"; + const fixtureFiles = { + [entryPath]: entrySource, + ...files, + }; + for (const [relativePath, source] of Object.entries(fixtureFiles)) { + const filePath = join(root, ...relativePath.split("/")); + mkdirSync(join(filePath, ".."), { recursive: true }); + writeFileSync(filePath, source); + } + const manifest = { + version: 1, + suites: { + [suitePath]: { + testTotal: 1, + entry: { + path: entryPath, + ceiling: 1, + }, + files: { + "case.rs": 2, + }, + ...suite, + }, + }, + }; + const options = { requiredSuites: [suitePath] }; + return { entryPath, manifest, options, root, suitePath }; +} + +function removeFixture(root) { + rmSync(root, { force: true, recursive: true }); +} + +function checkFixture(fixtureState) { + return checkIntegrationTestCeilings( + fixtureState.root, + fixtureState.manifest, + fixtureState.options, + ); +} + +test("normalizes host path separators before manifest comparison", () => { + assert.equal( + repoPath(String.raw`crates\gateway\tests\it\realtime_stt\protocol.rs`), + "crates/gateway/tests/it/realtime_stt/protocol.rs", + ); +}); + +test("counts physical lines independent of newline convention", () => { + assert.equal(physicalLineCount("#[test]\nfn case() {}\n"), 2); + assert.equal(physicalLineCount("#[test]\r\nfn case() {}\r\n"), 2); + assert.equal(physicalLineCount("#[test]\nfn case() {}"), 2); +}); + +test("accepts exact file coverage, line ceilings, and test total", () => { + const fixtureState = fixture({ + "crates/demo/tests/it/split/case.rs": "#[test]\nfn case() {}\n", + }); + try { + assert.doesNotThrow(() => checkFixture(fixtureState)); + } finally { + removeFixture(fixtureState.root); + } +}); + +test("fails closed when a manifest file is missing", () => { + const fixtureState = fixture({}); + try { + assert.throws( + () => checkFixture(fixtureState), + /missing manifested integration test file.*case\.rs/, + ); + } finally { + removeFixture(fixtureState.root); + } +}); + +test("fails closed when an extra Rust file is discovered", () => { + const fixtureState = fixture({ + "crates/demo/tests/it/split/case.rs": "#[test]\nfn case() {}\n", + "crates/demo/tests/it/split/extra.rs": "", + }); + try { + assert.throws( + () => checkFixture(fixtureState), + /unmanifested integration test file.*extra\.rs/, + ); + } finally { + removeFixture(fixtureState.root); + } +}); + +test("fails closed when a file exceeds its physical-line ceiling", () => { + const fixtureState = fixture({ + "crates/demo/tests/it/split/case.rs": + "#[test]\nfn case() {\n assert!(true);\n}\n", + }); + try { + assert.throws( + () => checkFixture(fixtureState), + /case\.rs has 4 physical lines, ceiling is 2/, + ); + } finally { + removeFixture(fixtureState.root); + } +}); + +test("fails closed when the exact test total drifts", () => { + const fixtureState = fixture( + { + "crates/demo/tests/it/split/case.rs": + "#[test]\nfn first() {}\n\n#[tokio::test]\nasync fn second() {}\n", + }, + { + files: { + "case.rs": 5, + }, + }, + ); + try { + assert.throws( + () => checkFixture(fixtureState), + /split has 2 tests, expected exactly 1/, + ); + } finally { + removeFixture(fixtureState.root); + } +}); + +test("fails closed when manifest paths are not repository-normalized", () => { + const fixtureState = fixture({ + "crates/demo/tests/it/split/case.rs": "#[test]\nfn case() {}\n", + }); + fixtureState.manifest.suites = { + [String.raw`crates\demo\tests\it\split`]: + fixtureState.manifest.suites[fixtureState.suitePath], + }; + fixtureState.options.requiredSuites = [String.raw`crates\demo\tests\it\split`]; + try { + assert.throws( + () => checkFixture(fixtureState), + /suite path must use normalized repository separators/, + ); + } finally { + removeFixture(fixtureState.root); + } +}); + +test("does not count a test attribute inside a block comment", () => { + const source = [ + "/*", + "#[test]", + "fn commented_out() {}", + "*/", + "", + ].join("\n"); + const fixtureState = fixture( + { "crates/demo/tests/it/split/case.rs": source }, + { + files: { + "case.rs": 4, + }, + }, + ); + try { + assert.throws( + () => checkFixture(fixtureState), + /split has 0 tests, expected exactly 1/, + ); + } finally { + removeFixture(fixtureState.root); + } +}); + +test("counts a multiline supported test attribute", () => { + const source = [ + "#[", + " tokio::test(", + ' flavor = "current_thread"', + " )", + "]", + "async fn case() {}", + "", + ].join("\n"); + const fixtureState = fixture( + { "crates/demo/tests/it/split/case.rs": source }, + { + files: { + "case.rs": 6, + }, + }, + ); + try { + assert.doesNotThrow(() => checkFixture(fixtureState)); + } finally { + removeFixture(fixtureState.root); + } +}); + +test("fails closed when a cfg-disabled test replaces a discovered test", () => { + const fixtureState = fixture( + { + "crates/demo/tests/it/split/case.rs": + "#[cfg(any())]\n#[test]\nfn disabled() {}\n", + }, + { + files: { + "case.rs": 3, + }, + }, + ); + try { + assert.throws( + () => checkFixture(fixtureState), + /cfg-gated test is unsupported.*case\.rs/, + ); + } finally { + removeFixture(fixtureState.root); + } +}); + +test("fails closed when a macro-contained test replaces a discovered test", () => { + const source = [ + "macro_rules! generated_test {", + " () => {", + " #[test]", + " fn generated() {}", + " };", + "}", + "", + ].join("\n"); + const fixtureState = fixture( + { "crates/demo/tests/it/split/case.rs": source }, + { + files: { + "case.rs": 6, + }, + }, + ); + try { + assert.throws( + () => checkFixture(fixtureState), + /macro-generated test is unsupported.*case\.rs/, + ); + } finally { + removeFixture(fixtureState.root); + } +}); + +test("fails closed when a suite entry exceeds its physical-line ceiling", () => { + const fixtureState = fixture( + { + "crates/demo/tests/it/split/case.rs": "#[test]\nfn case() {}\n", + }, + {}, + 'include!("split/case.rs");\n\n', + ); + try { + assert.throws( + () => checkFixture(fixtureState), + /split\.rs has 2 physical lines, ceiling is 1/, + ); + } finally { + removeFixture(fixtureState.root); + } +}); + +test("fails closed when a required include is missing", () => { + const fixtureState = fixture( + { + "crates/demo/tests/it/split/case.rs": "#[test]\nfn case() {}\n", + }, + {}, + "", + ); + try { + assert.throws( + () => checkFixture(fixtureState), + /suite include coverage differs.*missing.*split\/case\.rs/, + ); + } finally { + removeFixture(fixtureState.root); + } +}); + +test("fails closed when a required include is replaced", () => { + const fixtureState = fixture( + { + "crates/demo/tests/it/split/case.rs": "#[test]\nfn case() {}\n", + }, + {}, + 'include!("split/replacement.rs");\n', + ); + try { + assert.throws( + () => checkFixture(fixtureState), + /suite include coverage differs: missing \["split\/case\.rs"\], extra \["split\/replacement\.rs"\]/, + ); + } finally { + removeFixture(fixtureState.root); + } +}); + +test("fails closed when a required include appears twice", () => { + const fixtureState = fixture( + { + "crates/demo/tests/it/split/case.rs": "#[test]\nfn case() {}\n", + }, + { + entry: { + path: "crates/demo/tests/it/split.rs", + ceiling: 2, + }, + }, + 'include!("split/case.rs");\ninclude!("split/case.rs");\n', + ); + try { + assert.throws( + () => checkFixture(fixtureState), + /suite include must appear exactly once.*split\/case\.rs.*found 2/, + ); + } finally { + removeFixture(fixtureState.root); + } +}); + +test("fails closed when a required suite is omitted", () => { + const fixtureState = fixture({ + "crates/demo/tests/it/split/case.rs": "#[test]\nfn case() {}\n", + }); + fixtureState.manifest.suites = {}; + try { + assert.throws( + () => checkFixture(fixtureState), + /suite coverage differs: missing \["crates\/demo\/tests\/it\/split"\], extra \[\]/, + ); + } finally { + removeFixture(fixtureState.root); + } +}); + +test("fails closed when a required suite is replaced by an undeclared suite", () => { + const fixtureState = fixture({ + "crates/demo/tests/it/split/case.rs": "#[test]\nfn case() {}\n", + }); + fixtureState.manifest.suites = { + "crates/demo/tests/it/replacement": + fixtureState.manifest.suites[fixtureState.suitePath], + }; + try { + assert.throws( + () => checkFixture(fixtureState), + /suite coverage differs: missing \["crates\/demo\/tests\/it\/split"\], extra \["crates\/demo\/tests\/it\/replacement"\]/, + ); + } finally { + removeFixture(fixtureState.root); + } +}); diff --git a/tools/check-stt-architecture.mjs b/tools/check-stt-architecture.mjs new file mode 100644 index 00000000..60b52b38 --- /dev/null +++ b/tools/check-stt-architecture.mjs @@ -0,0 +1,677 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync, readdirSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const CARGO_MODULES_VERSION = "0.25.0"; +const CARGO_PUBLIC_API_VERSION = "0.52.0"; +const CARGO_VERSION = "1.89.0"; +const CARGO_TOOLCHAIN = "1.89.0"; +const RUSTDOC_TOOLCHAIN = "nightly-2026-09-05"; +const STT_CRATES = [ + "gateway-stt", + "gateway-stt-engine", + "gateway-stt-backend-whisper", + "gateway-whisper-ffi", +]; +const FIXTURE_STT_CRATES = new Set(["gateway-stt", "gateway-stt-engine"]); + +function fail(message) { + throw new Error(message); +} + +function maskRustCommentsAndLiterals(source) { + const masked = source.split(""); + const blank = (index) => { + if (masked[index] !== "\n" && masked[index] !== "\r") { + masked[index] = " "; + } + }; + let index = 0; + while (index < source.length) { + if (source.startsWith("//", index)) { + while (index < source.length && source[index] !== "\n") { + blank(index); + index += 1; + } + continue; + } + if (source.startsWith("/*", index)) { + let depth = 1; + blank(index); + blank(index + 1); + index += 2; + while (index < source.length && depth > 0) { + if (source.startsWith("/*", index)) { + depth += 1; + blank(index); + blank(index + 1); + index += 2; + } else if (source.startsWith("*/", index)) { + depth -= 1; + blank(index); + blank(index + 1); + index += 2; + } else { + blank(index); + index += 1; + } + } + continue; + } + + const raw = /^(?:br|r)(#*)"/.exec(source.slice(index)); + if (raw !== null) { + const terminator = `"${raw[1]}`; + let end = source.indexOf(terminator, index + raw[0].length); + end = end === -1 ? source.length : end + terminator.length; + while (index < end) { + blank(index); + index += 1; + } + continue; + } + + const quoteOffset = + source[index] === '"' ? 0 : source[index] === "b" && source[index + 1] === '"' ? 1 : -1; + if (quoteOffset !== -1) { + const openingQuote = index + quoteOffset; + while (index <= openingQuote) { + blank(index); + index += 1; + } + let escaped = false; + while (index < source.length) { + const character = source[index]; + blank(index); + index += 1; + if (character === '"' && !escaped) { + break; + } + escaped = character === "\\" && !escaped; + if (character !== "\\") { + escaped = false; + } + } + continue; + } + + const characterLength = + source[index] === "'" && source[index + 1] === "\\" + ? source[index + 3] === "'" + ? 4 + : 0 + : source[index] === "'" && source[index + 2] === "'" + ? 3 + : 0; + if (characterLength > 0) { + const end = index + characterLength; + while (index < end) { + blank(index); + index += 1; + } + continue; + } + index += 1; + } + return masked.join(""); +} + +function allowsDeadCode(attribute) { + const pattern = /\ballow\s*\(/g; + for (const match of attribute.matchAll(pattern)) { + const open = match.index + match[0].lastIndexOf("("); + let depth = 1; + let index = open + 1; + while (index < attribute.length && depth > 0) { + if (attribute[index] === "(") { + depth += 1; + } else if (attribute[index] === ")") { + depth -= 1; + } + index += 1; + } + if ( + depth === 0 && + /\bdead_code\b/.test(attribute.slice(open + 1, index - 1)) + ) { + return true; + } + } + return false; +} + +function attributeEnd(source, start) { + let index = start; + while (/\s/.test(source[index] ?? "")) { + index += 1; + } + if (source[index] !== "#") { + return undefined; + } + index += 1; + while (/\s/.test(source[index] ?? "")) { + index += 1; + } + if (source[index] === "!") { + index += 1; + while (/\s/.test(source[index] ?? "")) { + index += 1; + } + } + if (source[index] !== "[") { + return undefined; + } + let depth = 1; + index += 1; + while (index < source.length && depth > 0) { + if (source[index] === "[") { + depth += 1; + } else if (source[index] === "]") { + depth -= 1; + } + index += 1; + } + return depth === 0 ? index : undefined; +} + +function targetsModule(source, afterAttribute) { + let index = afterAttribute; + for (;;) { + while (/\s/.test(source[index] ?? "")) { + index += 1; + } + const nextAttribute = attributeEnd(source, index); + if (nextAttribute === undefined) { + break; + } + index = nextAttribute; + } + return /^(?:(?:pub(?:\s*\([^)]*\))?|unsafe)\s+)*mod\b/.test(source.slice(index)); +} + +export function broadDeadCodeAllowances(source) { + const masked = maskRustCommentsAndLiterals(source); + const findings = []; + for (let index = 0; index < masked.length; index += 1) { + if (masked[index] !== "#") { + continue; + } + const end = attributeEnd(masked, index); + if (end === undefined) { + continue; + } + const attribute = masked.slice(index, end); + const inner = /^#\s*!/.test(attribute); + if (allowsDeadCode(attribute) && (inner || targetsModule(masked, end))) { + findings.push(source.slice(0, index).split(/\r?\n/).length); + } + index = end - 1; + } + return findings; +} + +export function readRustSources(root) { + const sources = []; + function collect(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + collect(path); + } else if (entry.isFile() && entry.name.endsWith(".rs")) { + sources.push({ path, source: readFileSync(path, "utf8") }); + } + } + } + collect(root); + return sources.sort((left, right) => left.path.localeCompare(right.path)); +} + +export function requireNoBroadDeadCodeAllowances(sources) { + for (const { path, source } of sources) { + const lines = broadDeadCodeAllowances(source); + if (lines.length > 0) { + fail(`${path}:${lines.join(",")}: broad dead-code allowance is forbidden`); + } + } +} + +export function requireToolVersion(tool, output, expected) { + const actual = output.trim(); + if (actual !== `${tool} ${expected}`) { + fail(`architecture gate requires ${tool} ${expected}, got ${JSON.stringify(actual)}`); + } +} + +export function requireCargoVersion(output) { + const actual = output.trim(); + if (!actual.startsWith(`cargo ${CARGO_VERSION} `)) { + fail(`architecture gate requires Cargo ${CARGO_VERSION}, got ${JSON.stringify(actual)}`); + } +} + +function moduleOwner(item, modules) { + return modules.find( + (module) => item === module || item.startsWith(`${module}::`), + ); +} + +export function parseCargoModulesDot(output) { + const nodes = new Set(); + const crateNodes = []; + const rawEdges = []; + const value = String.raw`(?:"[^"\\]*"|[A-Za-z_][A-Za-z0-9_]*|\d+(?:\.\d+)?)`; + const attributes = String.raw`\[(?:[A-Za-z_][A-Za-z0-9_]*=${value})(?:,\s*[A-Za-z_][A-Za-z0-9_]*=${value})*\]`; + const nodePattern = new RegExp( + String.raw`^\s*"([^"\\]+)"\s+${attributes};\s*// "(crate|mod)" node\s*$`, + ); + const edgePattern = new RegExp( + String.raw`^\s*"([^"\\]+)"\s+->\s+"([^"\\]+)"(?:\s+${attributes})+;\s*// "uses" edge\s*$`, + ); + const attributePattern = new RegExp( + String.raw`^[A-Za-z_][A-Za-z0-9_]*\s*=\s*${value},\s*$`, + ); + let sawDigraph = false; + let sawClose = false; + let attributeBlock; + + for (const line of output.split(/\r?\n/)) { + const statement = line.trim(); + if (statement.length === 0) { + continue; + } + if (!sawDigraph && statement === "digraph {") { + sawDigraph = true; + continue; + } + if (!sawDigraph || sawClose) { + fail(`malformed cargo-modules DOT statement: ${statement}`); + } + if (attributeBlock !== undefined) { + if (statement === "];") { + attributeBlock = undefined; + } else if ( + !statement.startsWith("//") && + !attributePattern.test(statement) + ) { + fail(`malformed cargo-modules DOT ${attributeBlock} attribute: ${statement}`); + } + continue; + } + if (statement === "}") { + sawClose = true; + continue; + } + const block = /^(graph|node|edge) \[$/.exec(statement); + if (block !== null) { + attributeBlock = block[1]; + continue; + } + if (line.includes('// "crate" node') || line.includes('// "mod" node')) { + const match = nodePattern.exec(line); + if (match === null) { + fail(`malformed cargo-modules DOT node: ${line.trim()}`); + } + if (nodes.has(match[1])) { + fail(`malformed cargo-modules DOT output: duplicate node ${match[1]}`); + } + nodes.add(match[1]); + if (match[2] === "crate") { + crateNodes.push(match[1]); + } + continue; + } + if (line.includes('// "uses" edge')) { + const match = edgePattern.exec(line); + if (match === null) { + fail(`malformed cargo-modules DOT edge: ${line.trim()}`); + } + rawEdges.push([match[1], match[2]]); + continue; + } + fail(`malformed cargo-modules DOT statement: ${statement}`); + } + + if (!sawDigraph || !sawClose || attributeBlock !== undefined) { + fail("malformed cargo-modules DOT output: expected one complete digraph"); + } + + if (crateNodes.length !== 1) { + fail( + `malformed cargo-modules DOT output: expected one crate node, got ${crateNodes.length}`, + ); + } + const crate = crateNodes[0]; + for (const node of nodes) { + if (node !== crate && !node.startsWith(`${crate}::`)) { + fail(`malformed cargo-modules DOT output: node ${node} is outside ${crate}`); + } + } + + const modules = [...nodes].sort((left, right) => right.length - left.length); + const graph = new Map(modules.map((module) => [module, new Set()])); + for (const [rawSource, rawTarget] of rawEdges) { + const source = moduleOwner(rawSource, modules); + const target = moduleOwner(rawTarget, modules); + if (source === undefined || target === undefined) { + const item = source === undefined ? rawSource : rawTarget; + fail( + `malformed cargo-modules DOT output: ${item} does not belong to a declared module`, + ); + } + if (source !== target) { + graph.get(source).add(target); + } + } + return graph; +} + +export function assertAcyclic(graph, graphName) { + const state = new Map(); + const stack = []; + + function visit(node) { + if (state.get(node) === 2) { + return; + } + if (state.get(node) === 1) { + const start = stack.indexOf(node); + fail(`${graphName} module cycle: ${[...stack.slice(start), node].join(" -> ")}`); + } + if (!graph.has(node)) { + fail(`${graphName} graph references unknown module ${node}`); + } + state.set(node, 1); + stack.push(node); + for (const target of graph.get(node)) { + visit(target); + } + stack.pop(); + state.set(node, 2); + } + + for (const node of graph.keys()) { + visit(node); + } +} + +function escapedRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function countEffectiveRootNames(output, crateName) { + const lines = output.split(/\r?\n/).filter((line) => line.length > 0); + if (lines[0] !== `pub mod ${crateName}`) { + fail( + `malformed cargo-public-api output for ${crateName}: missing crate root declaration`, + ); + } + + const pathPattern = new RegExp( + `\\b${escapedRegExp(crateName)}::(r#[A-Za-z_][A-Za-z0-9_]*|[A-Za-z_][A-Za-z0-9_]*)`, + "g", + ); + const names = new Set(); + for (const line of lines.slice(1)) { + if (!/^(?:#\[[^\]]+\]\s+)?(?:pub|impl)\b/.test(line)) { + fail(`malformed cargo-public-api output for ${crateName}: ${line}`); + } + const matches = [...line.matchAll(pathPattern)]; + if (matches.length === 0) { + fail( + `malformed cargo-public-api output for ${crateName}: item has no crate path: ${line}`, + ); + } + for (const match of matches) { + names.add(match[1]); + } + } + return names.size; +} + +export function publicRootCount(source, crateName) { + const matches = [ + ...source.matchAll(/^\s*public_root_count\s*=\s*(\d+)\s*$/gm), + ]; + if (matches.length !== 1) { + fail( + `${crateName}/module-ceilings.toml must contain exactly one integer public_root_count`, + ); + } + return Number(matches[0][1]); +} + +export function testFixturePublicRootCount(source, crateName) { + const matches = [ + ...source.matchAll( + /^\s*test_fixture_public_root_count\s*=\s*(\d+)\s*$/gm, + ), + ]; + if (matches.length !== 1) { + fail( + `${crateName}/module-ceilings.toml must contain exactly one integer test_fixture_public_root_count`, + ); + } + return Number(matches[0][1]); +} + +export function requireExactPublicRootCount(crateName, actual, expected) { + if (actual !== expected) { + fail( + `${crateName} exposes ${actual} effective root names, expected exactly ${expected}`, + ); + } +} + +function canonicalPublicApi(output, crateName) { + const canonical = output.replaceAll("\r\n", "\n"); + if (canonical.includes("\r") || !canonical.endsWith("\n")) { + fail( + `malformed cargo-public-api output for ${crateName}: expected LF-terminated lines`, + ); + } + countEffectiveRootNames(canonical, crateName); + return canonical; +} + +export function requireExactPublicApi(crateName, actual, expected) { + const actualCanonical = canonicalPublicApi(actual, crateName); + const expectedCanonical = canonicalPublicApi(expected, crateName); + if (actualCanonical !== expectedCanonical) { + fail( + `${crateName} feature-enabled public API differs from its exact snapshot`, + ); + } +} + +export function requireNoFixtureApi(crateName, output, expected) { + const canonical = canonicalPublicApi(output, crateName); + const expectedCanonical = canonicalPublicApi(expected, crateName); + if (canonical !== expectedCanonical) { + fail(`${crateName} default build exposes fixture API`); + } +} + +export function runCargo( + root, + args, + { spawn = spawnSync, env = process.env } = {}, +) { + const result = spawn("cargo", args, { + cwd: root, + encoding: "utf8", + env: { ...env, RUSTUP_TOOLCHAIN: CARGO_TOOLCHAIN }, + maxBuffer: 64 * 1024 * 1024, + windowsHide: true, + }); + if (result.error !== undefined) { + fail(`cargo ${args[0]} failed to start: ${result.error.message}`); + } + if (result.status !== 0) { + fail( + `cargo ${args.join(" ")} failed with status ${result.status}\n${result.stderr}`, + ); + } + return result.stdout; +} + +export function runRustdocCargo( + root, + args, + { spawn = spawnSync, env = process.env } = {}, +) { + const commandArgs = [`+${RUSTDOC_TOOLCHAIN}`, ...args]; + const result = spawn("cargo", commandArgs, { + cwd: root, + encoding: "utf8", + env: { ...env, RUSTUP_TOOLCHAIN: RUSTDOC_TOOLCHAIN }, + maxBuffer: 64 * 1024 * 1024, + windowsHide: true, + }); + if (result.error !== undefined) { + fail(`cargo ${commandArgs.join(" ")} failed to start: ${result.error.message}`); + } + if (result.status !== 0) { + fail( + `cargo ${commandArgs.join(" ")} failed with status ${result.status}\n${result.stderr}`, + ); + } + return result.stdout; +} + +export function runPublicApi( + root, + crateName, + { features = [], spawn = spawnSync, env = process.env } = {}, +) { + const manifestPath = join(root, "crates", crateName, "Cargo.toml"); + const featureArgs = + features.length === 0 ? [] : ["--features", features.join(",")]; + return runRustdocCargo( + root, + [ + "public-api", + "--manifest-path", + manifestPath, + "--package", + crateName, + ...featureArgs, + "-sss", + "--color", + "never", + ], + { spawn, env }, + ); +} + +function checkNodeVersion() { + const major = Number(process.versions.node.split(".")[0]); + if (!Number.isInteger(major) || major < 22) { + fail(`architecture gate requires Node.js 22 or later, got ${process.versions.node}`); + } +} + +function main() { + checkNodeVersion(); + const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + requireNoBroadDeadCodeAllowances( + readRustSources(join(root, "crates", "gateway-stt", "src")), + ); + requireCargoVersion(runCargo(root, ["--version"])); + requireToolVersion( + "cargo-modules", + runCargo(root, ["modules", "--version"]), + CARGO_MODULES_VERSION, + ); + requireToolVersion( + "cargo-public-api", + runRustdocCargo(root, ["public-api", "--version"]), + CARGO_PUBLIC_API_VERSION, + ); + + for (const crateName of STT_CRATES) { + const dot = runCargo(root, [ + "modules", + "dependencies", + "--lib", + "-p", + crateName, + "--no-externs", + "--no-fns", + "--no-sysroot", + "--no-traits", + "--no-types", + "--no-owns", + "--layout", + "dot", + ]); + assertAcyclic(parseCargoModulesDot(dot), crateName); + + const publicApi = runPublicApi(root, crateName); + const rootNames = countEffectiveRootNames( + publicApi, + crateName.replaceAll("-", "_"), + ); + const ceilingPath = join(root, "crates", crateName, "module-ceilings.toml"); + const expected = publicRootCount(readFileSync(ceilingPath, "utf8"), crateName); + requireExactPublicRootCount(crateName, rootNames, expected); + console.log(`${crateName}: acyclic, public roots ${rootNames}`); + + if (FIXTURE_STT_CRATES.has(crateName)) { + const defaultSnapshotPath = join( + root, + "crates", + crateName, + "public-api-default.txt", + ); + requireNoFixtureApi( + crateName.replaceAll("-", "_"), + publicApi, + readFileSync(defaultSnapshotPath, "utf8"), + ); + const fixturePublicApi = runPublicApi(root, crateName, { + features: ["test-fixtures"], + }); + const snapshotPath = join( + root, + "crates", + crateName, + "public-api-test-fixtures.txt", + ); + requireExactPublicApi( + crateName.replaceAll("-", "_"), + fixturePublicApi, + readFileSync(snapshotPath, "utf8"), + ); + const fixtureRootNames = countEffectiveRootNames( + fixturePublicApi, + crateName.replaceAll("-", "_"), + ); + const expectedFixtureRoots = testFixturePublicRootCount( + readFileSync(ceilingPath, "utf8"), + crateName, + ); + requireExactPublicRootCount( + `${crateName} test-fixtures`, + fixtureRootNames, + expectedFixtureRoots, + ); + console.log( + `${crateName}: test-fixtures API exact, public roots ${fixtureRootNames}`, + ); + } + } +} + +const invokedPath = + process.argv[1] === undefined + ? undefined + : pathToFileURL(resolve(process.argv[1])).href; +if (invokedPath === import.meta.url) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} diff --git a/tools/check-stt-architecture.test.mjs b/tools/check-stt-architecture.test.mjs new file mode 100644 index 00000000..ae84fb68 --- /dev/null +++ b/tools/check-stt-architecture.test.mjs @@ -0,0 +1,444 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + assertAcyclic, + broadDeadCodeAllowances, + countEffectiveRootNames, + parseCargoModulesDot, + publicRootCount, + readRustSources, + requireExactPublicApi, + requireCargoVersion, + requireExactPublicRootCount, + requireNoBroadDeadCodeAllowances, + requireNoFixtureApi, + testFixturePublicRootCount, + requireToolVersion, + runCargo, + runPublicApi, + runRustdocCargo, +} from "./check-stt-architecture.mjs"; + +test("gateway-stt keeps module dead-code diagnostics active", () => { + const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + const sources = readRustSources( + join(root, "crates", "gateway-stt", "src"), + ); + + assert.ok(sources.length > 1); + assert.doesNotThrow(() => requireNoBroadDeadCodeAllowances(sources)); +}); + +test("dead-code guard rejects equivalent broad attributes", () => { + for (const source of [ + '#[allow(dead_code, reason = "temporary")] pub(crate) mod hidden;', + "#[cfg_attr(not(test), allow(dead_code))]\n#[cfg(unix)]\nmod hidden;", + "#! [ allow ( dead_code, reason = \"module contents\" ) ]\nfn hidden() {}", + ]) { + assert.deepEqual(broadDeadCodeAllowances(source), [1]); + assert.throws( + () => requireNoBroadDeadCodeAllowances([{ path: "fixture.rs", source }]), + /fixture\.rs:1: broad dead-code allowance is forbidden/, + ); + } +}); + +test("dead-code guard ignores item allowances and inert text", () => { + const source = String.raw` +// #[allow(dead_code)] mod commented; +const TEXT: &str = "#![allow(dead_code)]"; +#![cfg_attr(test, allow(unused), deny(dead_code))] +#[cfg_attr(not(test), allow(dead_code, reason = "drop ownership"))] +field: Resource, +`; + assert.deepEqual(broadDeadCodeAllowances(source), []); +}); + +test("DOT parser collapses item edges to their owning modules", () => { + const graph = parseCargoModulesDot(` +digraph { + "demo" [label="crate|demo"]; // "crate" node + "demo::alpha" [label="mod|alpha"]; // "mod" node + "demo::beta" [label="mod|beta"]; // "mod" node + "demo::alpha::Thing" -> "demo::beta::Other" [label="uses"]; // "uses" edge +} +`); + + assert.deepEqual([...graph.get("demo::alpha")], ["demo::beta"]); + assert.doesNotThrow(() => assertAcyclic(graph, "demo")); +}); + +test("cycle checker catches collapsed module cycles", () => { + const graph = parseCargoModulesDot(` +digraph { + "demo" [label="crate|demo"]; // "crate" node + "demo::alpha" [label="mod|alpha"]; // "mod" node + "demo::beta" [label="mod|beta"]; // "mod" node + "demo::alpha::Thing" -> "demo::beta::Other" [label="uses"]; // "uses" edge + "demo::beta::Other" -> "demo::alpha::Thing" [label="uses"]; // "uses" edge +} +`); + + assert.throws(() => assertAcyclic(graph, "demo"), /alpha.*beta.*alpha/); +}); + +test("DOT parser rejects edges outside declared module nodes", () => { + assert.throws( + () => + parseCargoModulesDot(` +digraph { + "demo" [label="crate|demo"]; // "crate" node + "demo::alpha" [label="mod|alpha"]; // "mod" node + "demo::alpha::Thing" -> "other::Thing" [label="uses"]; // "uses" edge +} +`), + /does not belong to a declared module/, + ); +}); + +test("DOT parser rejects a malformed edge that would complete a cycle", () => { + assert.throws( + () => + parseCargoModulesDot(` +digraph { + "demo" [label="crate|demo"]; // "crate" node + "demo::alpha" [label="mod|alpha"]; // "mod" node + "demo::beta" [label="mod|beta"]; // "mod" node + "demo::alpha::Thing" -> "demo::beta::Other" [label="uses"]; // "uses" edge + "demo::beta::Other" -> BROKEN +} +`), + /malformed cargo-modules DOT statement/, + ); +}); + +test("public API parser counts unique effective root names", () => { + const count = countEffectiveRootNames( + ` +pub mod demo +pub struct demo::One +impl demo::One +pub fn demo::One::new() -> Self +pub type demo::Alias = demo::One +`, + "demo", + ); + + assert.equal(count, 2); +}); + +test("public API parser rejects malformed output", () => { + assert.throws( + () => countEffectiveRootNames("pub mod demo\nnot public API output\n", "demo"), + /malformed cargo-public-api output/, + ); +}); + +test("public root count is exact rather than a spare budget", () => { + assert.equal(publicRootCount("public_root_count = 6\n", "demo"), 6); + assert.doesNotThrow(() => requireExactPublicRootCount("demo", 6, 6)); + assert.throws( + () => requireExactPublicRootCount("demo", 5, 6), + /expected exactly 6/, + ); + assert.throws( + () => requireExactPublicRootCount("demo", 7, 6), + /expected exactly 6/, + ); +}); + +test("public API snapshots normalize CRLF and fail closed on drift", () => { + const snapshot = [ + "pub mod demo", + "pub mod demo::test_fixtures", + "pub struct demo::test_fixtures::Fixture", + "", + ].join("\n"); + assert.doesNotThrow(() => requireExactPublicApi("demo", snapshot, snapshot)); + assert.doesNotThrow(() => + requireExactPublicApi("demo", snapshot.replaceAll("\n", "\r\n"), snapshot), + ); + assert.doesNotThrow(() => + requireExactPublicApi("demo", snapshot, snapshot.replaceAll("\n", "\r\n")), + ); + assert.throws( + () => + requireExactPublicApi( + "demo", + snapshot.replace( + "\n", + "\npub fn demo::test_fixtures::added()\n", + ), + snapshot, + ), + /feature-enabled public API differs from its exact snapshot/, + ); + assert.throws( + () => + requireExactPublicApi( + "demo", + snapshot.replace( + "pub struct demo::test_fixtures::Fixture\n", + "", + ), + snapshot, + ), + /feature-enabled public API differs from its exact snapshot/, + ); +}); + +test("public API snapshots reject empty and bare-CR tool output", () => { + const snapshot = "pub mod demo\npub struct demo::Public\n"; + assert.throws( + () => requireExactPublicApi("demo", "", ""), + /malformed cargo-public-api output/, + ); + assert.throws( + () => requireExactPublicApi("demo", snapshot, ""), + /malformed cargo-public-api output/, + ); + assert.throws( + () => + requireExactPublicApi( + "demo", + "pub mod demo\rpub struct demo::Public\r", + snapshot, + ), + /expected LF-terminated lines/, + ); +}); + +test("default public API must match its exact non-fixture snapshot", () => { + const snapshot = [ + "pub mod demo", + "pub struct demo::Public", + "impl demo::Public", + "", + ].join("\n"); + assert.doesNotThrow(() => + requireNoFixtureApi("demo", snapshot, snapshot), + ); + assert.throws( + () => + requireNoFixtureApi( + "demo", + snapshot.replace( + "impl demo::Public\n", + "impl demo::Public\npub fn demo::Public::block_realtime_send_after()\n", + ), + snapshot, + ), + /default build exposes fixture API/, + ); + assert.throws( + () => + requireNoFixtureApi( + "demo", + snapshot.replace("pub struct demo::Public\n", ""), + snapshot, + ), + /default build exposes fixture API/, + ); +}); + +test("feature public root count is an exact module-ceiling record", () => { + assert.equal( + testFixturePublicRootCount( + "public_root_count = 6\ntest_fixture_public_root_count = 7\n", + "demo", + ), + 7, + ); + assert.throws( + () => testFixturePublicRootCount("public_root_count = 6\n", "demo"), + /exactly one integer test_fixture_public_root_count/, + ); +}); + +test("tool version parser rejects an unpinned version", () => { + assert.throws( + () => requireToolVersion("cargo-modules", "cargo-modules 0.26.0\n", "0.25.0"), + /requires cargo-modules 0\.25\.0/, + ); +}); + +test("Cargo version parser rejects ambient Cargo 1.98", () => { + assert.throws( + () => requireCargoVersion("cargo 1.98.0 (797e8a9bc 2026-08-05)\n"), + /requires Cargo 1\.89\.0/, + ); +}); + +test("cargo-modules child cannot inherit ambient Cargo 1.98", () => { + let child; + runCargo("repo", ["modules", "--version"], { + env: { AMBIENT_CARGO_VERSION: "1.98.0", PATH: "rustup", RUSTUP_TOOLCHAIN: "stable" }, + spawn(command, args, options) { + child = { command, args, options }; + return { status: 0, stdout: "cargo-modules 0.25.0\n", stderr: "" }; + }, + }); + + assert.equal(child.command, "cargo"); + assert.deepEqual(child.args, ["modules", "--version"]); + assert.equal(child.options.env.AMBIENT_CARGO_VERSION, "1.98.0"); + assert.equal(child.options.env.PATH, "rustup"); + assert.equal(child.options.env.RUSTUP_TOOLCHAIN, "1.89.0"); +}); + +test("cargo-public-api child cannot inherit ambient Cargo 1.98", () => { + let child; + runRustdocCargo("repo", ["public-api", "--version"], { + env: { AMBIENT_CARGO_VERSION: "1.98.0", PATH: "rustup", RUSTUP_TOOLCHAIN: "stable" }, + spawn(command, args, options) { + child = { command, args, options }; + return { status: 0, stdout: "cargo-public-api 0.52.0\n", stderr: "" }; + }, + }); + + assert.equal(child.command, "cargo"); + assert.deepEqual(child.args, [ + "+nightly-2026-09-05", + "public-api", + "--version", + ]); + assert.equal(child.options.env.AMBIENT_CARGO_VERSION, "1.98.0"); + assert.equal(child.options.env.PATH, "rustup"); + assert.equal(child.options.env.RUSTUP_TOOLCHAIN, "nightly-2026-09-05"); +}); + +test("public API command keeps exact package selection", () => { + let child; + runPublicApi("repo", "gateway-stt", { + env: { RUSTUP_TOOLCHAIN: "stable" }, + spawn(command, args, options) { + child = { command, args, options }; + return { status: 0, stdout: "pub mod gateway_stt\n", stderr: "" }; + }, + }); + + assert.equal(child.command, "cargo"); + assert.deepEqual(child.args, [ + "+nightly-2026-09-05", + "public-api", + "--manifest-path", + join("repo", "crates", "gateway-stt", "Cargo.toml"), + "--package", + "gateway-stt", + "-sss", + "--color", + "never", + ]); + assert.equal(child.options.env.RUSTUP_TOOLCHAIN, "nightly-2026-09-05"); +}); + +test("feature public API command enables only test fixtures", () => { + let child; + runPublicApi("repo", "gateway-stt-engine", { + features: ["test-fixtures"], + spawn(command, args, options) { + child = { command, args, options }; + return { + status: 0, + stdout: "pub mod gateway_stt_engine\n", + stderr: "", + }; + }, + }); + + assert.deepEqual(child.args, [ + "+nightly-2026-09-05", + "public-api", + "--manifest-path", + join("repo", "crates", "gateway-stt-engine", "Cargo.toml"), + "--package", + "gateway-stt-engine", + "--features", + "test-fixtures", + "-sss", + "--color", + "never", + ]); +}); + +test("public API command fails closed when the pinned nightly is absent", () => { + assert.throws( + () => + runPublicApi("repo", "gateway-stt", { + spawn(command, args) { + assert.equal(command, "cargo"); + assert.equal(args[0], "+nightly-2026-09-05"); + return { + status: 1, + stdout: "", + stderr: + "error: toolchain 'nightly-2026-09-05-x86_64-unknown-linux-gnu' is not installed", + }; + }, + }), + /cargo \+nightly-2026-09-05 public-api.*failed with status 1.*toolchain 'nightly-2026-09-05-x86_64-unknown-linux-gnu' is not installed/s, + ); +}); + +test("public API failure never falls back to the virtual workspace manifest", () => { + const calls = []; + assert.throws( + () => + runPublicApi("repo", "gateway-stt", { + spawn(command, args) { + calls.push({ command, args }); + return { + status: 1, + stdout: "", + stderr: + "`Cargo.toml` is a virtual manifest; workspace API listing is unsupported", + }; + }, + }), + /failed with status 1.*virtual manifest/s, + ); + + assert.equal(calls.length, 1); + const manifestIndex = calls[0].args.indexOf("--manifest-path"); + assert.equal( + calls[0].args[manifestIndex + 1], + join("repo", "crates", "gateway-stt", "Cargo.toml"), + ); + assert.notEqual(calls[0].args[manifestIndex + 1], join("repo", "Cargo.toml")); +}); + +test("architecture cargo fails closed when Rust 1.89 is absent", () => { + assert.throws( + () => + runCargo("repo", ["--version"], { + spawn() { + return { + status: 1, + stdout: "", + stderr: "toolchain '1.89.0' is not installed", + }; + }, + }), + /failed with status 1.*toolchain '1\.89\.0' is not installed/s, + ); +}); + +test("architecture cargo fails closed when a required tool is absent", () => { + assert.throws( + () => + runCargo("repo", ["modules", "--version"], { + spawn() { + return { + status: 101, + stdout: "", + stderr: "no such command: `modules`", + }; + }, + }), + /failed with status 101.*no such command: `modules`/s, + ); +}); diff --git a/tools/check-stt-native-workflow.test.mjs b/tools/check-stt-native-workflow.test.mjs new file mode 100644 index 00000000..5e43ad0e --- /dev/null +++ b/tools/check-stt-native-workflow.test.mjs @@ -0,0 +1,465 @@ +import assert from "node:assert/strict"; +import { + chmodSync, + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test, { after, before } from "node:test"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const workflow = readFileSync( + join(root, ".github", "workflows", "stt-miri.yml"), + "utf8", +); +const cargoManifest = readFileSync(join(root, "Cargo.toml"), "utf8"); +const rustToolchain = readFileSync(join(root, "rust-toolchain.toml"), "utf8"); +const fixtureRoot = mkdtempSync(join(tmpdir(), "promptforge-rust-preflight-")); +const fakeToolSource = join(fixtureRoot, "fake-rust-tool.rs"); +const fakeTool = join( + fixtureRoot, + process.platform === "win32" ? "fake-rust-tool.exe" : "fake-rust-tool", +); +const preflightScript = join(fixtureRoot, "preflight.ps1"); + +function jobSource(name) { + const marker = ` ${name}:\n`; + const start = workflow.indexOf(marker); + assert.notEqual(start, -1, `missing ${name} job`); + const remainder = workflow.slice(start + marker.length); + const nextJob = remainder.match(/^ [A-Za-z0-9_-]+:\r?$/m); + const end = nextJob + ? start + marker.length + nextJob.index + : workflow.length; + return workflow.slice(start, end); +} + +function stepScript(job, name) { + const marker = ` - name: ${name}\n`; + const start = job.indexOf(marker); + assert.notEqual(start, -1, `missing ${name} step`); + const remainder = job.slice(start + marker.length); + const run = /^ run: \|\r?\n/m.exec(remainder); + assert.ok(run, `missing run block for ${name}`); + const body = remainder.slice(run.index + run[0].length); + const nextStep = body.search(/^ - name: /m); + const source = nextStep === -1 ? body : body.slice(0, nextStep); + return source + .split(/\r?\n/) + .map((line) => line.startsWith(" ") ? line.slice(10) : line) + .join("\n") + .trimEnd(); +} + +function createToolLayout(names, { bin, proxy = false } = {}) { + bin ??= mkdtempSync( + join(fixtureRoot, proxy ? "proxy-bin-" : "direct-bin-"), + ); + mkdirSync(bin, { recursive: true }); + for (const name of names) { + const destination = join(bin, `${name}.exe`); + copyFileSync(fakeTool, destination); + chmodSync(destination, 0o755); + } + return bin; +} + +function environmentKey(environment, name) { + return Object.keys(environment).find( + (key) => key.toLowerCase() === name.toLowerCase(), + ); +} + +function setEnvironmentVariable(environment, name, value) { + const key = environmentKey(environment, name) ?? name; + environment[key] = value; +} + +function deleteEnvironmentVariable(environment, name) { + const key = environmentKey(environment, name); + if (key) { + delete environment[key]; + } +} + +function runPreflight({ + bin, + discovery, + explicitBin, + windowsDirectory, +}) { + const environment = { ...process.env }; + const runRoot = mkdtempSync(join(fixtureRoot, "preflight-run-")); + const githubPath = join(runRoot, "github-path"); + const mode = discovery ?? (explicitBin ? "contract" : "path"); + + deleteEnvironmentVariable(environment, "PROMPTFORGE_RUST_1_89_0_BIN"); + setEnvironmentVariable(environment, "GITHUB_PATH", githubPath); + setEnvironmentVariable(environment, "RUSTUP_TOOLCHAIN", "1.89"); + setEnvironmentVariable(environment, "RUSTUP_AUTO_INSTALL", "0"); + + if (mode === "contract") { + setEnvironmentVariable( + environment, + "PROMPTFORGE_RUST_1_89_0_BIN", + bin, + ); + } else if (mode === "path") { + const pathKey = environmentKey(environment, "PATH") ?? "PATH"; + setEnvironmentVariable( + environment, + "PATH", + `${bin}${delimiter}${environment[pathKey] ?? ""}`, + ); + } else if (mode === "network-service" || mode === "isolated") { + const emptyPath = join(runRoot, "empty-path"); + const emptyProfile = join(runRoot, "empty-profile"); + mkdirSync(emptyPath, { recursive: true }); + mkdirSync(emptyProfile, { recursive: true }); + deleteEnvironmentVariable(environment, "CARGO_HOME"); + setEnvironmentVariable(environment, "PATH", emptyPath); + setEnvironmentVariable(environment, "USERPROFILE", emptyProfile); + setEnvironmentVariable(environment, "WINDIR", windowsDirectory); + } else { + throw new Error(`unknown preflight discovery mode: ${mode}`); + } + + const executable = process.platform === "win32" + ? join( + process.env.SystemRoot ?? "C:\\WINDOWS", + "System32", + "WindowsPowerShell", + "v1.0", + "powershell.exe", + ) + : "pwsh"; + const result = spawnSync( + executable, + ["-NoProfile", "-NonInteractive", "-File", preflightScript], + { + cwd: root, + encoding: "utf8", + env: environment, + timeout: 30_000, + }, + ); + return { ...result, githubPath }; +} + +before(() => { + writeFileSync( + fakeToolSource, + String.raw`use std::env; + +fn main() { + let executable = env::current_exe().expect("current executable"); + let name = executable + .file_stem() + .expect("executable stem") + .to_string_lossy() + .to_ascii_lowercase(); + let is_proxy = executable + .parent() + .and_then(|parent| parent.file_name()) + .is_some_and(|parent| parent.to_string_lossy().starts_with("proxy-bin-")); + if env::args().nth(1).is_some_and(|arg| arg.starts_with('+')) && !is_proxy { + std::process::exit(2); + } + match name.as_str() { + "cargo" => println!("cargo 1.89.0 (fixture 2026-09-07)"), + "rustc" => println!("rustc 1.89.0 (fixture 2026-09-07)"), + "rustup" => println!("rustup 1.28.2 (fixture 2026-09-07)"), + _ => panic!("unexpected fake tool name: {name}"), + } +} +`, + ); + const compiled = spawnSync("rustc", [fakeToolSource, "-o", fakeTool], { + cwd: root, + encoding: "utf8", + timeout: 30_000, + }); + assert.equal( + compiled.status, + 0, + `failed to compile fake Rust tools:\n${compiled.stdout}${compiled.stderr}`, + ); + writeFileSync( + preflightScript, + stepScript(jobSource("native-whisper"), "Verify preinstalled MSRV Rust"), + ); +}); + +after(() => { + rmSync(fixtureRoot, { force: true, recursive: true }); +}); + +test("native runner validates the exact repository MSRV before caching", () => { + const native = jobSource("native-whisper"); + const preflight = native.indexOf("- name: Verify preinstalled MSRV Rust"); + const cache = native.indexOf("- name: Cache Cargo"); + const resolveCargo = native.indexOf( + "$cargo = Resolve-RustTool -Name 'cargo' -Bin $rustBin", + ); + const resolveRustc = native.indexOf( + "$rustc = Resolve-RustTool -Name 'rustc' -Bin $rustBin", + ); + const validateCargo = native.indexOf( + "Assert-RustToolVersion -Name 'cargo' -ToolPath $cargo", + ); + const validateRustc = native.indexOf( + "Assert-RustToolVersion -Name 'rustc' -ToolPath $rustc", + ); + const publishContract = native.indexOf( + "$rustBin | Add-Content -Path $env:GITHUB_PATH", + ); + + assert.ok(preflight > 0, "native job must have a Rust preflight"); + assert.ok(cache > preflight, "Rust preflight must run before Cargo caching"); + assert.ok(resolveCargo > preflight, "preflight must resolve cargo.exe"); + assert.ok(resolveRustc > resolveCargo, "preflight must resolve rustc.exe"); + assert.ok(validateCargo > resolveRustc, "preflight must validate Cargo"); + assert.ok(validateRustc > validateCargo, "preflight must validate rustc"); + assert.ok( + publishContract > validateRustc, + "contract bin must not reach PATH before both versions pass", + ); + assert.ok(cache > publishContract, "both versions must pass before caching"); + assert.match(cargoManifest, /^rust-version = "1\.89"$/m); + assert.match(rustToolchain, /^channel = "1\.89"$/m); + assert.match(native, /^\s+RUSTUP_TOOLCHAIN: 1\.89$/m); + assert.match(native, /^\s+RUSTUP_AUTO_INSTALL: "0"$/m); + assert.match(native, /\$requiredVersion = '1\.89\.0'/); + assert.doesNotMatch(native, /RUSTUP_TOOLCHAIN: stable/); +}); + +test("direct tools run from the versioned contract without rustup", () => { + const bin = createToolLayout(["cargo", "rustc"]); + const result = runPreflight({ bin, explicitBin: true }); + + assert.equal( + result.status, + 0, + `direct preflight failed:\n${result.stdout}${result.stderr}`, + ); + assert.match(result.stdout, /Using cargo 1\.89\.0 from /); + assert.match(result.stdout, /Using rustc 1\.89\.0 from /); + assert.match(result.stdout, /Using direct Rust tools/); + assert.doesNotMatch(result.stdout, /Using rustup proxies/); +}); + +test("rustup-managed PATH proxies use the exact preinstalled toolchain", () => { + const bin = createToolLayout(["cargo", "rustc", "rustup"], { proxy: true }); + const result = runPreflight({ bin, explicitBin: false }); + + assert.equal( + result.status, + 0, + `proxy preflight failed:\n${result.stdout}${result.stderr}`, + ); + assert.match(result.stdout, /Using cargo 1\.89\.0 from /); + assert.match(result.stdout, /Using rustc 1\.89\.0 from /); + assert.match(result.stdout, /Using rustup proxies from /); +}); + +test("NetworkService profile discovery executes without a repository variable", () => { + const windowsDirectory = mkdtempSync( + join(fixtureRoot, "windows-directory-"), + ); + const serviceBin = join( + windowsDirectory, + "ServiceProfiles", + "NetworkService", + ".rustup", + "toolchains", + "1.89.0-x86_64-pc-windows-msvc", + "bin", + ); + createToolLayout(["cargo", "rustc"], { bin: serviceBin }); + + const result = runPreflight({ + discovery: "network-service", + windowsDirectory, + }); + + assert.equal( + result.status, + 0, + `NetworkService discovery failed:\n${result.stdout}${result.stderr}`, + ); + assert.match( + result.stdout, + /Discovered preprovisioned Rust bin from NetworkService rustup toolchain 1\.89\.0-x86_64-pc-windows-msvc:/, + ); + assert.match(result.stdout, /Using cargo 1\.89\.0 from /); + assert.match(result.stdout, /Using rustc 1\.89\.0 from /); + assert.equal(readFileSync(result.githubPath, "utf8").trim(), serviceBin); +}); + +test("tool discovery uses only explicit bounded candidate directories", () => { + const native = jobSource("native-whisper"); + + assert.match( + native, + /\$contractName = 'PROMPTFORGE_RUST_1_89_0_BIN'/, + ); + assert.match(native, /\[IO\.Path\]::IsPathRooted\(\$contractBin\)/); + assert.match(native, /Test-Path \$contractBin -PathType Container/); + assert.match( + native, + /\$networkServiceProfile = Join-Path \$windowsDirectory 'ServiceProfiles\\NetworkService'/, + ); + assert.match( + native, + /Join-Path \$networkServiceProfile '\.rustup'/, + ); + assert.match( + native, + /"\$requiredVersion-x86_64-pc-windows-msvc"/, + ); + assert.match( + native, + /"\$env:RUSTUP_TOOLCHAIN-x86_64-pc-windows-msvc"/, + ); + assert.match(native, /-Source 'NetworkService service profile'/); + assert.match(native, /Join-Path \$cargoHome 'bin'/); + assert.match(native, /Join-Path \$userProfile '\.cargo\\bin'/); + assert.match(native, /\$candidate = Join-Path \$Bin "\$Name\.exe"/); + assert.match( + native, + /Get-Command 'cargo\.exe' -CommandType Application -ErrorAction SilentlyContinue/, + ); + assert.match(native, /\$rustBin \| Add-Content -Path \$env:GITHUB_PATH/); + assert.doesNotMatch(native, /Get-ChildItem/); + assert.doesNotMatch(native, /-Recurse/); +}); + +test("rustup proxies remain pinned and cannot auto-install", () => { + const native = jobSource("native-whisper"); + const proxyProbe = native.indexOf( + "$cargoIsRustupProxy = Test-RustupProxy -ToolPath $cargo", + ); + const resolveRustup = native.indexOf( + "$rustup = Resolve-RustTool -Name 'rustup' -Bin $rustBin", + ); + + assert.match(native, /^\s+RUSTUP_TOOLCHAIN: 1\.89$/m); + assert.match(native, /^\s+RUSTUP_AUTO_INSTALL: "0"$/m); + assert.ok(proxyProbe > 0, "selected cargo must be checked as a proxy"); + assert.ok( + resolveRustup > proxyProbe, + "rustup must be resolved only after the selected cargo is a proxy", + ); + assert.match(native, /\$versionLines = @\(& \$ToolPath '--version' 2>&1\)/); + assert.doesNotMatch(native, /rustup toolchain list/); + assert.doesNotMatch(native, /'\+stable'/); +}); + +test("native preflight rejects wrong and unrecognized tool versions", () => { + const native = jobSource("native-whisper"); + + assert.match( + native, + /\\s\+\(\\d\+\\\.\\d\+\\\.\\d\+\)\(\?:\\s\|\$\)/, + ); + assert.match(native, /if \(-not \$versionMatch\.Success\)/); + assert.match(native, /returned an unrecognized version at/); + assert.match(native, /\$actualVersion = \$versionMatch\.Groups\[1\]\.Value/); + assert.match(native, /if \(\$actualVersion -ne \$requiredVersion\)/); + assert.match( + native, + /required repository MSRV is exactly \$requiredVersion/, + ); + assert.match(native, /Reprovision it outside CI or point \$contractName/); +}); + +test("native preflight reports actionable missing-tool failures", () => { + const native = jobSource("native-whisper"); + + assert.match( + native, + /no bounded candidate directory contained cargo\.exe and rustc\.exe/, + ); + assert.match( + native, + /Provision both tools together outside CI or set \$contractName to their absolute versioned bin directory/, + ); + assert.match( + native, + /selected bin from \$rustBinSource is missing \$Name\.exe at '\$candidate'/, + ); + assert.match(native, /\$Name\.exe failed at '\$ToolPath' with exit code/); + assert.match(native, /provision Rust \$requiredVersion outside CI/); +}); + +test("missing bounded candidates report every checked source", () => { + const windowsDirectory = mkdtempSync( + join(fixtureRoot, "empty-windows-directory-"), + ); + const result = runPreflight({ + discovery: "isolated", + windowsDirectory, + }); + const output = `${result.stdout}${result.stderr}`.replace(/\s+/g, " "); + + assert.notEqual(result.status, 0, "missing tools must fail the preflight"); + assert.match(output, /no bounded candidate directory contained cargo\.exe and rustc\.exe/); + assert.match(output, /USERPROFILE '/); + assert.match(output, /NetworkService service profile '/); + assert.match(output, /PROMPTFORGE_RUST_1_89_0_BIN/); + assert.match(output, /Provision both tools together outside CI/); +}); + +test("native runner contains no Rust installer action", () => { + const native = jobSource("native-whisper"); + + assert.doesNotMatch(native, /dtolnay\/rust-toolchain/); + assert.doesNotMatch( + native, + /rustup(?:-init)?(?:\.exe)?\s+(?:install|default|self update)/i, + ); +}); + +test("all native Whisper work stays on the Windows CUDA runner", () => { + const native = jobSource("native-whisper"); + + assert.match(native, /^\s+runs-on: \[self-hosted, windows, cuda\]$/m); + assert.match(native, /Test safe Whisper backend integration/); + assert.match(native, /Test native prompt budgets/); + assert.match(native, /Test native Whisper FFI/); + assert.match(native, /Test native Gateway STT units/); + assert.match(native, /Test native Gateway STT integration/); +}); + +test("hosted Miri job keeps its pinned nightly setup", () => { + const miri = jobSource("pure-stt-state"); + + assert.match(miri, /uses: dtolnay\/rust-toolchain@nightly/); + assert.match(miri, /toolchain: nightly-2026-09-05/); + assert.match(miri, /components: miri/); + assert.match(miri, /cargo \+nightly-2026-09-05 miri setup/); +}); + +test("native fixture artifact hashes remain pinned", () => { + const native = jobSource("native-whisper"); + + assert.match( + native, + /F1BC54D7288E21EE826CCB5767249836B780FC316BEC4A0374873E73163DAE12/, + ); + assert.match( + native, + /921E4CF8686FDD993DCD081A5DA5B6C365BFDE1162E72B08D75AC75289920B1F/, + ); + assert.match( + native, + /59DFB9A4ACB36FE2A2AFFC14BACBEE2920FF435CB13CC314A08C13F66BA7860E/, + ); +}); diff --git a/tools/document.md b/tools/document.md index 591d47aa..203b703b 100644 --- a/tools/document.md +++ b/tools/document.md @@ -111,7 +111,7 @@ Template: the Tour. Dependency order. Each chapter builds on the last. Audience: the gateway operator. -Targets: `crates/gateway/`, `crates/gateway-config/`, `crates/gateway-config-ui/`, `crates/gateway-local/`, `crates/shared-loopback/`, `crates/shared-protocol/`, `crates/gateway-routing/`, `crates/gateway-stt/`, `crates/gateway-transcribe/`, `crates/gateway-web-search/`, `crates/gateway-whisper-ffi/`, `gateway.local.example.toml`. +Targets: `crates/gateway/`, `crates/gateway-config/`, `crates/gateway-config-ui/`, `crates/gateway-local/`, `crates/shared-loopback/`, `crates/shared-protocol/`, `crates/gateway-routing/`, `crates/gateway-stt/`, `crates/gateway-stt-engine/`, `crates/gateway-web-search/`, `crates/gateway-whisper-ffi/`, `gateway.local.example.toml`. Extract: what the operator configures and observes. Every configuration key and what it does. Profiles. The configuration UI. The HTTP endpoints. Startup and provisioning behavior. Profile switching. Health and logs. Noise: internal machinery as features (wire types, transport internals, test infrastructure) and the Rust public API. Most files yield zero or one operator-facing features. That is expected. The empty extractions are the proof. Output: `guide/src/gateway/`. diff --git a/tools/integration-test-ceilings.json b/tools/integration-test-ceilings.json new file mode 100644 index 00000000..b7bf8171 --- /dev/null +++ b/tools/integration-test-ceilings.json @@ -0,0 +1,51 @@ +{ + "version": 1, + "suites": { + "crates/gateway/tests/it/realtime_stt": { + "testTotal": 19, + "entry": { + "path": "crates/gateway/tests/it/realtime_stt.rs", + "ceiling": 567 + }, + "files": { + "authentication.rs": 89, + "canonical_sequence.rs": 102, + "capacity.rs": 207, + "lifecycle.rs": 149, + "overload.rs": 65, + "protocol.rs": 372, + "recovery.rs": 147, + "scheduling.rs": 90 + } + }, + "crates/workshop-server/tests/it/chat_gate": { + "testTotal": 11, + "entry": { + "path": "crates/workshop-server/tests/it/chat_gate.rs", + "ceiling": 371 + }, + "files": { + "canonical_sequence.rs": 51, + "lifecycle.rs": 276, + "overload.rs": 46, + "protocol.rs": 54, + "recovery.rs": 301 + } + }, + "crates/workshop-server/tests/it/realtime_relay": { + "testTotal": 9, + "entry": { + "path": "crates/workshop-server/tests/it/realtime_relay.rs", + "ceiling": 371 + }, + "files": { + "authentication.rs": 115, + "canonical_sequence.rs": 49, + "lifecycle.rs": 27, + "overload.rs": 27, + "protocol.rs": 59, + "recovery.rs": 33 + } + } + } +} diff --git a/tools/stage-gateway-sidecar.mjs b/tools/stage-gateway-sidecar.mjs new file mode 100644 index 00000000..b033008c --- /dev/null +++ b/tools/stage-gateway-sidecar.mjs @@ -0,0 +1,135 @@ +import { + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + rmSync, +} from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const TARGETS = new Map([ + [ + "x86_64-pc-windows-msvc", + { + binary: "promptforge-gateway.exe", + sidecar: "promptforge-gateway-x86_64-pc-windows-msvc.exe", + }, + ], + [ + "x86_64-unknown-linux-gnu", + { + binary: "promptforge-gateway", + sidecar: "promptforge-gateway-x86_64-unknown-linux-gnu", + }, + ], +]); + +const REPOSITORY_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + +function targetNames(target) { + const names = TARGETS.get(target); + if (names === undefined) { + throw new Error(`unsupported Gateway sidecar target: ${target}`); + } + return names; +} + +export function gatewayBinaryName(target) { + return targetNames(target).binary; +} + +export function gatewaySidecarName(target) { + return targetNames(target).sidecar; +} + +function sidecarPath(root, target) { + return join( + root, + "crates", + "workshop", + "binaries", + gatewaySidecarName(target), + ); +} + +export function stageGatewaySidecar({ root = REPOSITORY_ROOT, target, source }) { + const expectedSourceName = gatewayBinaryName(target); + const sourcePath = resolve(source); + if (!existsSync(sourcePath)) { + throw new Error(`Gateway source binary does not exist: ${sourcePath}`); + } + if (!lstatSync(sourcePath).isFile()) { + throw new Error(`Gateway source binary is not a file: ${sourcePath}`); + } + if (basename(sourcePath) !== expectedSourceName) { + throw new Error( + `Gateway source binary must be named ${expectedSourceName} for ${target}`, + ); + } + + const destination = sidecarPath(root, target); + mkdirSync(dirname(destination), { recursive: true }); + copyFileSync(sourcePath, destination); + return destination; +} + +export function removeGatewaySidecar({ root = REPOSITORY_ROOT, target }) { + const destination = sidecarPath(root, target); + rmSync(destination, { force: true }); + return destination; +} + +function parseArguments(args) { + const [action, ...options] = args; + if (action !== "stage" && action !== "remove") { + throw new Error("usage: stage-gateway-sidecar.mjs --target [--source ]"); + } + + const values = new Map(); + for (let index = 0; index < options.length; index += 2) { + const name = options[index]; + const value = options[index + 1]; + if ((name !== "--target" && name !== "--source") || value === undefined) { + throw new Error(`invalid sidecar argument: ${name ?? ""}`); + } + if (values.has(name)) { + throw new Error(`duplicate sidecar argument: ${name}`); + } + values.set(name, value); + } + + const target = values.get("--target"); + if (target === undefined) { + throw new Error("missing required sidecar argument: --target"); + } + const source = values.get("--source"); + if (action === "stage" && source === undefined) { + throw new Error("missing required sidecar argument: --source"); + } + if (action === "remove" && source !== undefined) { + throw new Error("remove does not accept --source"); + } + return { action, source, target }; +} + +function main(args) { + const { action, source, target } = parseArguments(args); + const path = + action === "stage" + ? stageGatewaySidecar({ source, target }) + : removeGatewaySidecar({ target }); + console.log(`${action === "stage" ? "staged" : "removed"} ${path}`); +} + +if ( + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + try { + main(process.argv.slice(2)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/tools/stage-gateway-sidecar.test.mjs b/tools/stage-gateway-sidecar.test.mjs new file mode 100644 index 00000000..787b28a6 --- /dev/null +++ b/tools/stage-gateway-sidecar.test.mjs @@ -0,0 +1,122 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; + +import { + gatewayBinaryName, + gatewaySidecarName, + removeGatewaySidecar, + stageGatewaySidecar, +} from "./stage-gateway-sidecar.mjs"; + +test("maps the Windows target to Tauri's suffixed executable name", () => { + assert.equal( + gatewaySidecarName("x86_64-pc-windows-msvc"), + "promptforge-gateway-x86_64-pc-windows-msvc.exe", + ); + assert.equal( + gatewayBinaryName("x86_64-pc-windows-msvc"), + "promptforge-gateway.exe", + ); +}); + +test("maps the Linux target to Tauri's suffix without an extension", () => { + assert.equal( + gatewaySidecarName("x86_64-unknown-linux-gnu"), + "promptforge-gateway-x86_64-unknown-linux-gnu", + ); + assert.equal( + gatewayBinaryName("x86_64-unknown-linux-gnu"), + "promptforge-gateway", + ); +}); + +test("rejects an unsupported target", () => { + assert.throws( + () => gatewaySidecarName("aarch64-apple-darwin"), + /unsupported Gateway sidecar target/, + ); +}); + +test("rejects a missing source binary", () => { + const root = mkdtempSync(join(tmpdir(), "promptforge-sidecar-")); + try { + assert.throws( + () => + stageGatewaySidecar({ + root, + target: "x86_64-pc-windows-msvc", + source: join(root, "target", "debug", "promptforge-gateway.exe"), + }), + /source binary does not exist/, + ); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); + +test("rejects a source binary whose platform name mismatches the target", () => { + const root = mkdtempSync(join(tmpdir(), "promptforge-sidecar-")); + const source = join(root, "target", "debug", "promptforge-gateway"); + try { + mkdirSync(join(root, "target", "debug"), { recursive: true }); + writeFileSync(source, "linux gateway"); + assert.throws( + () => + stageGatewaySidecar({ + root, + target: "x86_64-pc-windows-msvc", + source, + }), + /source binary must be named promptforge-gateway\.exe/, + ); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); + +test("stages and removes the real source file under Tauri's target name", () => { + const root = mkdtempSync(join(tmpdir(), "promptforge-sidecar-")); + const source = join(root, "target", "debug", "promptforge-gateway.exe"); + try { + mkdirSync(join(root, "target", "debug"), { recursive: true }); + writeFileSync(source, "compiled gateway"); + + const staged = stageGatewaySidecar({ + root, + target: "x86_64-pc-windows-msvc", + source, + }); + assert.equal( + staged, + join( + root, + "crates", + "workshop", + "binaries", + "promptforge-gateway-x86_64-pc-windows-msvc.exe", + ), + ); + assert.equal(readFileSync(staged, "utf8"), "compiled gateway"); + + assert.equal( + removeGatewaySidecar({ + root, + target: "x86_64-pc-windows-msvc", + }), + staged, + ); + assert.throws(() => readFileSync(staged), /ENOENT/); + assert.equal( + removeGatewaySidecar({ + root, + target: "x86_64-pc-windows-msvc", + }), + staged, + ); + } finally { + rmSync(root, { force: true, recursive: true }); + } +}); diff --git a/vibe/2026-09-05-1-gateway-logging-cli.md b/vibe/2026-09-05-1-gateway-logging-cli.md new file mode 100644 index 00000000..a2a4d8e1 --- /dev/null +++ b/vibe/2026-09-05-1-gateway-logging-cli.md @@ -0,0 +1,121 @@ +--- +name: gateway-logging-cli +overview: Separate Gateway CLI simplification, diagnostic discovery, and asynchronous bounded logging from the STT redesign. The work creates a small `gateway-logging` crate, removes the `serve` verb safely across every repository-owned caller, and makes failed runs discoverable without config knowledge. +todos: + - id: logging-cli + content: Characterize and migrate the Gateway CLI and every owned caller + status: completed + - id: logging-crate + content: Extract gateway-logging with bounded prioritized worker and shutdown ownership + status: completed + - id: logging-diagnostics + content: Add diagnostics, retention, fatal-chain capture, privacy, and pressure behavior + status: completed + - id: logging-verify + content: Update rules/docs and complete full verification + status: completed + - id: baseline-ratchet + content: Repair the pre-existing Workshop module ratchets + status: completed + - id: plan-activation + content: Commit this plan and activate it in vibe/ACTIVE + status: completed +isProject: false +--- + +# Gateway Logging and CLI + +## Design rationale + +*2026-09-05 - distilled from the producing session* + +The operator chose a separate logging crate because queue ownership, sink lifecycle, rotation, diagnostics, pressure behavior, and tests form an independently testable component. The operator also chose to remove the `serve` verb, use `--config PATH` for an explicit config, and make `diagnostics` emit JSON without an extra format flag. + +The final pressure policy is selective rather than fully lossless: on a full bounded queue, evict oldest Debug, then Trace, then Info; Warn and Error are never evicted; block only when the incoming level has no eligible lower-or-equal-priority record to evict. This supersedes the earlier zero-loss-for-all-levels idea. The queue remains bounded so a stalled sink cannot consume unbounded memory. + +Rejected alternatives: an unbounded intrusive list, because slow output becomes memory growth; synchronous writes on producer threads, because console or disk stalls can enter realtime paths; `shared-logging`, because only Gateway uses the component; a hidden `serve` compatibility alias, because it leaves a temporary CLI branch; configurable log paths, because config failures need a destination before config is usable; and separate human/JSON diagnostics modes, because one formatted JSON contract serves both. + +This is a Full-sized rulebook task because it changes a public CLI, installer and service callers, process startup/shutdown, and logging infrastructure. The run is deliberately lightened to four commits, one Coder and one Review-and-Fix pass per commit, and Verify only at rulebook-required component boundaries, every third commit, review-dirty steps, and final completion. + +## Outcome +- `promptforge-gateway` serves by default. +- `promptforge-gateway --config PATH` serves an explicit config. +- `promptforge-gateway diagnostics` emits formatted JSON without serving or rotating logs. +- Remove the `serve` verb, positional config path, and compatibility alias in one atomic migration. +- Extract logging from `gateway/src/main.rs` into `crates/gateway-logging` without moving global subscriber initialization out of the binary. +- Keep log memory bounded and preserve Warn/Error records through priority-aware eviction and blocking. + +## Public API and dependency boundary +- Add flat workspace crate `gateway-logging`; `gateway` is its only workspace consumer. +- `gateway-logging` depends only on the standard library, `tracing`, and `tracing-subscriber`. It never reads home, environment, Gateway config, sidecar state, or STT types. +- Export at most `LogConfig`, `LogRuntime`, `LogWriter`, and opaque `LogError`. +- `LogRuntime::start(LogConfig)` creates queues, sinks, and one worker thread. `LogRuntime::writer()` returns cloneable `LogWriter`. `LogRuntime::shutdown(self)` closes admission, drains, flushes, and joins. +- `LogWriter` implements `MakeWriter::make_writer_for` and derives priority only from tracing metadata. The returned `LogWriter` buffers all `Write` calls for one formatted event and enqueues its owned `Box` on drop, so partial formatter writes never become partial queue records. +- Queue nodes, sinks, rotation, mutexes, condition variables, and worker handles stay private. +- Gateway `main.rs` composes and globally installs the subscriber, holds `LogRuntime`, and shuts it down last. +- The crate sets `unsafe_code = "forbid"` in its manifest lint table. Public fields stay private; `LogError` preserves private sources and exposes classification methods. Every public item has rustdoc, error documentation, and compiled examples. + +## CLI contract +- Root invocation serves with existing discovery: `promptforge-gateway`. +- Explicit config uses `promptforge-gateway --config PATH`; it wins over `PROMPTFORGE_GATEWAY_CONFIG`. +- `promptforge-gateway diagnostics` is the only subcommand and always emits JSON. +- `--help`, `--version`, `diagnostics`, and second-instance handoff never initialize or rotate file logs. +- Update all owned callers atomically: `gateway/src/main.rs`, `gateway/src/boot.rs`, `gateway/src/tray/logic.rs`, `gateway/packaging/gateway.service`, `gateway/tests/it/boot.rs`, `workshop/src/gateway.rs`, `workshop/installer.nsi`, `.github/workflows/gateway-release-test.yml`, Gateway README, and install guide. Preserve `--login`, `--browser`, `--print-url`, `--no-tray`, and `--profile` behavior. + +## Logging path and lifecycle +- Gateway alone derives the state directory from `shared_sidecar::default_run_dir().parent()` and passes it to `LogConfig`. +- Logs remain under `/.promptforge/logs`; this is not configurable because config discovery and parsing failures need a destination. +- Startup order: parse CLI; handle help/version/diagnostics; detect an already-running Gateway; resolve state directory; start logging; resolve or generate config; log version/config/profile; create runtime; bind; write `gateway.json`; enqueue boot loading; enter event loop. +- Shutdown order: stop HTTP and tray work; stop commands, progress renderers, model runtimes, and native callbacks; log the terminal outcome; shut the logger down last. +- Fatal returned errors are logged once with the complete source chain, then the queue drains before process exit. Raw stderr is only the fallback when logger initialization fails. +- Retain `gateway.log` plus `gateway.log.1` through `gateway.log.5`. Rotate only when this process will serve, never during diagnostics or second-instance handoff. + +## Queue policy +- Constants: total capacity 8192 records, drain batch 256 records, retained runs 5. +- Private types: `LogPriority { Error, Warn, Info, Trace, Debug }`, `LogRecord { sequence, priority, line: Box }`, `LogQueue`, and `LogWorker`. +- Keep one deque per priority under one mutex and one fixed total capacity. Formatting and allocation happen before locking. The worker swaps a bounded batch to local storage and performs all writes outside the mutex. +- On full queue, evict oldest Debug, then oldest Trace, then oldest Info. Warn and Error are never evicted. +- Prevent inversion: Debug may evict only Debug; Trace may evict Debug or Trace; Info may evict Debug, Trace, or Info; Warn/Error may evict Debug, Trace, or Info. If no eligible record exists, block on a condition variable until space opens. +- Count evictions by level and emit one synthetic summary after pressure clears. Select the smallest global sequence among lane heads so retained output remains chronological. +- File-sink failure falls back to synchronous stderr. If every sink blocks and no eligible record exists, producer blocking is intentional. +- No log record may contain credentials, cookies, authorization headers, environment values, request bodies, audio, transcript text, prompts, or full local model paths. + +## Diagnostics contract +`promptforge-gateway diagnostics` returns formatted JSON with: +```json +{ + "state_dir": "...", + "config": { "path": "...", "exists": true }, + "logs": { + "current": { "path": ".../gateway.log", "exists": true }, + "retained": [ + { "path": ".../gateway.log.1", "exists": true } + ] + }, + "connection_file": { "path": ".../run/gateway.json", "exists": false }, + "running": false, + "version": "0.2.0" +} +``` +- It performs no logging initialization, rotation, config parsing, or mutation. +- It returns no bearer key, environment value, config content, or log content. +- Generated `gateway.toml` adds `# Diagnostics: promptforge-gateway diagnostics` as a comment, not a config field. + +## Numbered commits +1. Characterize current CLI, handoff, and launcher behavior in tests, then remove `serve` and positional config, add root serving with `--config PATH`, and update every repository-owned caller atomically. Focused gate: `cargo test -p gateway -p workshop`, followed by the Gateway release-test command fixture. +2. Add `gateway-logging`, move rotation/sink/worker ownership out of `gateway/src/main.rs`, install the bounded priority queue, preserve the default filter, and wire shutdown-last behavior. Focused gate: `cargo test -p gateway-logging -p gateway`. +3. Add five-run retention, JSON `diagnostics`, generated-config hint, fatal-chain capture, handoff-no-rotation, sink fallback, privacy, saturation, shutdown, and release-mode latency tests. Focused gate: `cargo test -p gateway-logging -p gateway`, followed by ignored release test `production_logging_stays_within_latency_budget`; normal sink throughput must add less than 2% or 1 ms, whichever is larger, to p95 enqueue-to-write latency. +4. Update AGENTS and final documentation, enforce the dependency boundary, and run full verification: formatting, all-target/all-feature Clippy with warnings denied, `cargo test -p gateway-logging -p gateway -p workshop`, Gateway featureless check, rustdoc, `cargo deny check`, packaged Gateway/Workshop builds, and a child-process failure followed by `diagnostics` log discovery. + +## Execution rules +- Repository: `C:\Users\Vinnie\cursor\promptforge`. +- Plan: `C:\Users\Vinnie\.cursor\plans\gateway-logging-cli_d7a036c4.plan.md`. +- Rulebooks: `C:\Users\Vinnie\cursor\tools-public\rulebooks\vibe-rulebook.md` and `C:\Users\Vinnie\cursor\tools-public\rulebooks\rust-rulebook.md`. +- Governing rules: root `AGENTS.md`, `crates/gateway/AGENTS.md`, `crates/workshop/AGENTS.md`, `crates/shared-sidecar/AGENTS.md`, and new `crates/gateway-logging/AGENTS.md` after commit 2. +- Scratch: `C:\Users\Vinnie\cursor\cabinet\_scratch\vibe-gateway-logging-cli\vibe-ledger.md` and `vibe-review.md`. +- Prerequisite: the separately accepted Workshop module-ratchet baseline task is green, the full existing suite passes, and the worktree is clean. Stop rather than stash or absorb unrelated changes. +- Before commit 1, copy this plan to the next dated `vibe/--gateway-logging-cli.md`, write its basename to `vibe/ACTIVE`, generate the plan commit message through the vibe rulebook, and commit both files. +- Each numbered item is one commit with code and tests. Use asynchronous Coder, Message, and Review-and-Fix subagents. Open findings block advancement. +- Light Verify schedule: focused tests only after commit 1 unless review changes code; fresh Verify after commit 2 as a component boundary, commit 3 as every third step and component boundary, and commit 4 with the full suite. +- Before every commit, run stable formatting checks and all-target/all-feature Clippy with warnings denied plus the focused gate. Public items receive rustdoc and tests in the same change. +- Two consecutive implementation failures or three failed verification rounds stop execution for re-planning. The tool never pushes. diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md new file mode 100644 index 00000000..0e0e5d4d --- /dev/null +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -0,0 +1,866 @@ +--- +name: generic-realtime-stt +overview: Replace Workshop-specific speech transcription with a generic OpenAI Realtime-compatible subsystem built around a small Gateway facade, a backend-neutral engine, a safe Whisper backend, and isolated bounded session ownership. +todos: + - id: characterize + content: Pin current batch and two-model realtime behavior with deterministic and native fixtures + status: pending + - id: backend-boundary + content: Split the backend-neutral engine from the safe Whisper adapter and bound model workers + status: pending + - id: session-lifecycle + content: Isolate per-item finalization and implement atomic cancellation-safe generation replacement + status: pending + - id: realtime-contract + content: Implement the OpenAI Realtime transcription subset and hypothesis extension + status: pending + - id: workshop-adapter + content: Convert Workshop to a payload-opaque relay with local capture and status ownership + status: pending + - id: verify-document + content: Enforce architecture and debt budgets, complete acceptance, and document final boundaries + status: pending + - id: gateway-log-bookends + content: Mark Gateway serving-log launch and terminal outcomes without changing logging infrastructure + status: pending + - id: ci-architecture-toolchain + content: Run pinned architecture tools under the repository Cargo version inside stable CI + status: completed + - id: ci-workshop-sidecars + content: Stage target-named Gateway sidecars before Windows and Linux Workshop CI builds + status: completed + - id: ci-native-rustup + content: Use the self-hosted Windows runner's preinstalled Rust without reinstalling rustup + status: completed + - id: windows-cache-sid + content: Restrict Windows artifact caches to the current process SID for users and service accounts + status: completed + - id: ci-session-retirement + content: Make session retirement verification event-driven instead of scheduler-yield-counted + status: completed + - id: ci-gateway-platform-warnings + content: Restore warnings-denied Gateway builds on non-Windows hosts + status: completed + - id: chat-model-selection + content: Prevent silent built-in chat turns when no model is selected + status: completed + - id: workshop-startup-convergence + content: Converge model catalog, Realtime readiness, and progress state after simultaneous startup + status: completed + - id: realtime-live-hypotheses + content: Bind precommit hypothesis item IDs to the active browser take + status: pending +isProject: false +--- + +# Generic Realtime STT + +## Product Requirements + +- Problem and users: + - PromptForge Gateway speech transcription is coupled to Workshop through custom routes, status frames, guards, and crate dependencies. + - The current two-model realtime implementation has global final-pass state, unbounded queues, backend-specific engine code, and profile-switch waits that cannot prove isolation or bounded completion. + - Workshop users need responsive dictation, external clients need a stable OpenAI-shaped transcription protocol, and Gateway maintainers need compiler-visible product and backend boundaries. +- Goals: + - Preserve `POST /v1/audio/transcriptions` for batch transcription by physical model name. + - Replace `/stt` and `/stt/capability` with `WS /v1/realtime?intent=transcription` using the supported OpenAI Realtime transcription event subset plus one documented hypothesis extension. + - Preserve the existing fast interim model, LocalAgreement-2, and accurate segment-final model behavior while making every session and committed item independent. + - Let clients share one loaded interim worker and at most one loaded final worker without copying models per client. + - Make Gateway depend on one small speech facade, keep the engine backend-neutral, and preserve the unsafe-only Whisper FFI leaf. + - Make Workshop a payload-opaque authenticated relay whose UI owns microphone capture, hypothesis presentation, and status wording. + - Reduce and enforce technical debt through dependency allowlists, module-cycle checks, bounded queues, public-surface budgets, and line-count ratchets. + - Close the operator-requested Gateway observability gap by making every serving file log start with a versioned launch record and end with a clean or fatal terminal record unless the process is killed. +- Non-goals: + - Dynamic backend plugins before a second backend exists. + - AlignAtt, attention-specific APIs, native streaming encoders, speculative decoding, batching, denoising, generic VAD tuning, or a new WER framework. + - A fifth STT crate or STT wire types in `shared-protocol`. + - Gateway CLI behavior, diagnostics, logging queues, sink lifecycle, log rotation, or the already-owned Workshop baseline-ratchet repair. The two serving-run bookend records in Step 41 are the sole logging exception. + - Browser-direct OpenAI authentication or WebRTC. Workshop remains the browser authentication proxy. +- Success criteria: + - The forbidden `gateway-stt -> workshop-server` dependency falls from one to zero and no Gateway STT crate contains Workshop-specific status or guard symbols. + - The final subsystem has exactly four STT crates: service, engine, safe Whisper backend, and unsafe-only FFI. + - All STT queues and session mailboxes are bounded, named, and tested; global mutable final-take state is zero. + - Multiple clients and overlapping committed items cannot exchange transcript history, completion channels, prompts, or stale results. + - The only live transcription endpoint is `/v1/realtime?intent=transcription`; batch transcription remains compatible. + - Default Gateway builds no longer transitively build Workshop UI assets. + - `gateway-stt` exports no free functions and at most six facade types; `gateway-stt-engine` exports at most eight root items; `gateway-stt-backend-whisper` exports exactly its backend and checked configuration. + - Every final STT source module is at most 500 physical lines, with ratchets preventing regrowth. + - Model-independent critical behavior runs in normal CI, native characterization passes with the packaged Whisper runtime, and synthetic Gateway, Workshop browser, packaged Windows microphone, cancellation, and second-take acceptance pass. +- Constraints: + - `gateway-whisper-ffi` remains the only STT crate permitted to contain unsafe code, load C symbols, own native pointers, or encode ABI layout. + - The public wire format is signed little-endian mono PCM16 at 24 kHz; the engine receives 16 kHz mono `f32` after one stateful conversion. + - Native decode and model loading are non-preemptible. Profile replacement must never pretend to kill native work or load a second generation beside live old-generation model state. + - New profile admission, old-generation draining, persistence, activation, rollback, and fatal shutdown must have explicit ownership and bounded control-plane behavior. + - The existing two-second closing silence, 500 ms interim cadence, 15-second interim window, 500 ms minimum decode, model pair, and decode policy remain unchanged during this structural migration. +- Open questions: + - None. + +## Functional Specification + +- Actors and workflows: + - A batch client uploads audio to `POST /v1/audio/transcriptions` and selects one active physical interim or final model. + - A Realtime client connects with exactly one transcription intent, receives `session.created`, optionally updates the effective transcription session, appends Base64 PCM16, observes negotiated live hypotheses, commits an input turn, and receives immediate item creation followed by asynchronous delta, completed, or failed events. + - Workshop accepts a same-origin browser socket, attaches the Gateway bearer upstream, forwards text and binary payloads without parsing JSON, and preserves close code and reason. + - The Workshop UI captures 24 kHz PCM16, renders each hypothesis as a replacement snapshot, replaces it with the authoritative completed transcript, and derives status locally. + - A profile switch closes generation admission, notifies old sessions, drains request and worker ownership, stages the replacement, coordinates profile persistence, then activates, rolls back, or performs controlled shutdown. +- Inputs and outputs: + - Supported client events are `session.update`, `input_audio_buffer.append`, `input_audio_buffer.commit`, and `input_audio_buffer.clear`. + - Supported server events are `session.created`, `session.updated`, `input_audio_buffer.committed`, `input_audio_buffer.cleared`, `conversation.item.created`, transcription delta, completed, failed, hypothesis, and error events. + - Session responses include `id`, `object: "realtime.transcription_session"`, `type: "transcription"`, the complete effective audio configuration, and `include`. + - Completion includes authoritative transcript and duration usage. Client event IDs are optional opaque strings echoed only in correlated errors; server event, session, and item IDs are independently generated opaque strings. + - The hypothesis extension contains revision, full transcript, finalized, agreed, tentative, and audio-span fields. Its transcript equals the exact concatenation of its three text components. +- States and validation: + - A connection starts ready with the advertised default logical model, 24 kHz PCM, null turn detection, and no optional includes. A valid update atomically replaces the session default and returns the full effective configuration. + - Each input buffer snapshots immutable format, model, prompt, and include configuration on its first append. Prompt changes affect the next buffer and never mutate an existing input or committed item. + - `turn_detection: null` is the sole supported turn-detection value. Non-null VAD, noise reduction, keywords, languages, delay, logprobs, unsupported models, and unknown include values are rejected without partially applying an update. + - The custom hypothesis include value is a PromptForge extension. When it is negotiated, Workshop ignores standard deltas for rendering and uses hypothesis replacement until completion. + - An input buffer owns its provisional item ID, audio and resampler state, interim task, LocalAgreement state, hypothesis, accurate final take, and any pending precommit failure. + - Commit reserves committed-item, terminal-mailbox, and bounded task-join capacity before changing the input. On success it seals interim work, promotes the same provisional item ID, records `previous_item_id` from durable commit order, emits committed and item-created immediately, and finalizes asynchronously. + - Up to four committed items per connection may finalize concurrently. Completion order may differ from commit order, and durable lineage survives removal of completed items. + - Clear cancels and retires only uncommitted work, resets partial PCM and resampler state, emits cleared, and leaves committed items untouched. + - Appends are limited to 15 MiB decoded audio; commits require at least 100 ms; unfinalized audio is capped at 30 seconds; excessive queue lag, item count, buffer size, or queue occupancy produces explicit overload behavior. +- Errors and recovery: + - Malformed JSON, unsupported fields, invalid PCM, short commits, unknown models, and safe overloads emit correlated errors while keeping the connection usable when state remains valid. + - If accurate precommit work fails, the input records a pending failure and rejects further appends. Commit first establishes the item and then emits exactly one item-scoped failure; clear discards the pending failure without inventing an item. + - A committed item whose authoritative segment cannot be admitted fails atomically rather than completing with a transcript hole. + - Socket sends have deadlines. Hypotheses may coalesce newest-wins, but accepted delta and terminal results are not internally dropped while the peer remains writable. End-to-end receipt is not claimed without peer acknowledgment. + - Engine replacement fails every committed in-flight item, emits a general error for uncommitted audio, and closes the session with code 1012 and reason `engine_replaced`. + - If old-generation work does not drain by the switch deadline, the old generation reopens with a fresh session epoch and replacement fails without closing worker ingress or loading new model memory. + - Ordinary staged-load or persistence failure rolls back by shutting the new generation down and reconstructing the old generation. Non-preemptible startup timeout or indeterminate persistence outcome leaves rollback unsafe and triggers controlled Gateway shutdown. +- Security and privacy behavior: + - Gateway applies its existing bearer, verified-cookie, or trusted-loopback authentication policy. Workshop always attaches the bearer upstream. + - Gateway accepts only absent Origin for native clients or HTTP loopback origins under its named loopback policy. Workshop separately requires the normalized browser Origin authority to match its validated request authority. + - Missing, duplicate, unknown, or conflicting Realtime routing parameters are rejected before upgrade. Workshop constructs the fixed upstream transcription target rather than forwarding arbitrary query text. + - The relay preserves message type, close code, and close reason, defines ping and pong ownership, and rejects unsupported subprotocol negotiation. + - PCM, Base64 audio, transcript text, prompts, vocabulary, credentials, cookies, request headers, and full local model paths never enter tracing fields. +- Acceptance criteria: + - Canonical event fixtures round-trip in Rust and are consumed unchanged by Workshop UI tests. + - Sequence tests prove first-event readiness, optional client IDs, immediate commit acknowledgment, provisional-ID promotion, configuration snapshot isolation, durable lineage, reversed completion order, clear semantics, and saturated-commit retry. + - Two clients sharing one engine remain isolated under interleaved interim, final, clear, commit, failure, and profile-switch activity. + - Packaged Windows Workshop records, revises hypotheses, completes, starts a second take, cancels a take, and reports permission or device failure as recoverable. + +## Technical Design + +- Architecture: + - `gateway` owns route mounting, authentication policy, profile-switch orchestration, operational status, and model catalog integration. + - `gateway-stt` owns artifact preparation, the `SpeechService` facade, active-generation lifecycle, batch and Realtime routes, session and item orchestration, take guidance, finalized history, segmentation, LocalAgreement state, transcript aggregation, completion and failure, wire translation, and error mapping. + - `gateway-stt-engine` owns backend-neutral decoder contracts, one bounded serialized worker per loaded physical model, stateless bounded decode jobs, worker cancellation, and engine policy. It owns no session, item, take, guidance history, transcript aggregation, completion channel, or item failure state. + - `gateway-stt-backend-whisper` owns safe Whisper model construction, prompt fitting, decode parameters, native-load progress, and backend error translation. + - `gateway-whisper-ffi` remains the unchanged unsafe-only runtime-loaded ABI leaf. + - `workshop-server` owns only the authenticated payload-opaque relay and generic speech-status mapping. Its UI owns capture and presentation. + - Core dependency direction is `gateway -> gateway-stt -> gateway-stt-engine`, `gateway-stt -> gateway-stt-backend-whisper`, and `gateway-stt-backend-whisper -> gateway-stt-engine + gateway-whisper-ffi`. No edge points from a Gateway STT crate to Workshop. +- Modules and interfaces: + - `SpeechService` is the cloneable Gateway handle. Its public supporting types are `PreparedSpeech`, opaque `SpeechReplacement`, `SpeechError`, `SpeechStatus`, and `SpeechModelInfo`; route handlers, wire structs, state, engines, and constants stay private. + - The speech lifecycle surface prepares verified artifacts, begins a serialized staged replacement, commits or aborts that replacement, invalidates staged state during fatal shutdown, reports status and models, returns routes, and shuts down. Gateway never selects workers or constructs wire events. + - The engine exports only `SttEngine`, `EnginePolicy`, one-method `ModelFactory`, one-method `Decoder`, `DecodeRequest`, `DecodeMode`, and `TranscribeError`. + - `DecodeRequest` carries one stateless decode job. `gateway-stt` owns immutable user guidance and finalized transcript history, derives the request prompt for each job, and never leaves that state in a decoder or worker. A decoder need not be sendable; construction and every decode occur on its owning worker thread. + - The safe Whisper backend exports only its backend and checked configuration. It exposes no FFI pointer, C symbol, session, route, profile, or Workshop type. + - Cross-crate access uses explicit crate-root re-exports. Internal modules remain private and public fields remain private. + - Shared loopback code owns separately named Gateway loopback-Origin and Workshop same-origin-authority predicates. Their security semantics are not conflated. +- File and public API changes: + - Rename `crates/gateway-transcribe` to `crates/gateway-stt-engine` without a compatibility crate. Move its native fixtures and ignore rule with it. + - Add `crates/gateway-stt-backend-whisper` and move safe model construction, prompts, native parameters, and progress reporting out of the engine. + - Preserve `crates/gateway-whisper-ffi` API and ABI tests. Do not move scheduling, prompt policy, model roles, or HTTP concepts into it. + - Replace the `gateway-stt` monolith with responsibility-named runtime, batch, Realtime session, audio, wire, and take modules. Add shared canonical Realtime fixtures under its tests. + - Replace `SttRuntime`, `SttState`, independent model-name locks, and slot publication with the speech facade and one complete engine snapshot. + - Move tuning from runtime use of `WorkshopSttConfig` and `[workshop.stt]` to `SttPipelineConfig` and canonical `[stt]`. Legacy input is accepted only when canonical input is absent, both forms together are invalid, and serialization writes only canonical form. + - Extend model metadata with transcription kind. Active physical names remain selectable for batch use and one logical `realtime-transcribe` model is advertised only while the pair is active. + - Extend generic Gateway operational status with configured, ready, GPU, and generation speech fields. A build without STT reports no speech object. + - Add Workshop `routes/realtime.rs` and a Realtime Gateway connector beside `routes/stt.rs`, the old connector, status parsing, and old UI. Convert the worklet to little-endian PCM16 and migrate the UI only after the additive relay passes; remove the old path only after installed-package microphone acceptance. + - Replace Workshop's old STT capability proxy with a Workshop-local dictation capability derived from generic Gateway speech status. + - Remove `/stt`, `/stt/capability`, Workshop status frames, custom status headers, legacy route exports, and the Workshop dependency only after the new Gateway route and Workshop consumer pass automated and physical-microphone acceptance. + - Treat `AGENTS.md` files as concise local constraints, not duplicate architecture documents. Delete obsolete ownership, dependency, route, configuration, and compatibility rules in the commit that makes them false; add only the minimum crate-specific rule needed to protect a new boundary. + - Keep root `AGENTS.md` and already-correct nested rules unchanged unless implementation exposes a concrete contradiction. The final rules audit prefers removing stale text over expanding rule files. +- Data, persistence, failure, security, and privacy constraints: + - One interim worker and optional final worker are shared by all clients. `gateway-stt-engine` owns `INTERIM_JOB_CAPACITY = 8` and `FINAL_JOB_CAPACITY = 8`; both are bounded synchronous queues with nonblocking overload responses, and opening sessions never creates OS threads. + - Each admitted worker job owns a generation work guard until cancellation is observed before decode or native decode returns. Request cancellation cannot make quiescence report false idleness. + - `gateway-stt::take::Take` is the only take abstraction. Each input or committed item owns one `Take` containing immutable guidance, finalized history, segment aggregation, completion, and failure. Final-model workers remain stateless between jobs. + - `gateway-stt` owns `MAX_ACTIVE_REALTIME_SESSIONS = 8` with no waiting admission queue and immediate rejection of the ninth session, plus `MAX_COMMITTED_ITEMS_PER_SESSION = 4`. + - Interim task epochs prevent post-commit or post-clear results from allocating event IDs or mutating later items. `gateway-stt` owns `SESSION_CANCEL_JOIN_CAPACITY = 8`; cancelled task handles are retained and joined through that bounded session-owned capacity. + - `gateway-stt` owns `SESSION_RESULT_CAPACITY = 16` plus one separately reserved terminal slot per committed item, one replaceable newest-wins hypothesis slot per item, and `FINAL_SEGMENT_CAPACITY = 4` per item. Authoritative segments and terminal outcomes do not use lossy admission. + - Audio decoding preserves odd-byte state, decodes little-endian samples explicitly, resamples continuously from 24 kHz to 16 kHz, flushes on commit, and fully resets on clear. + - Active snapshot publication includes generation, physical names, backend, engine, and admission state in one lock-bounded transition. Batch and Realtime admission borrow one complete generation. + - Every generation has an admission gate, explicit request and job ownership counts, and a replaceable session epoch. Quiescence installs a fresh epoch for possible rollback, cancels the old epoch, and waits for all old ownership to drain. + - Replacement is serialized. Artifact preparation starts no worker. After old work drains, old workers shut down without detachment, the new generation loads under one startup deadline, and it remains unpublished until profile persistence succeeds. + - Profile persistence prepares and syncs a temporary file before destructive replacement, atomically replaces the authoritative file after staging, and syncs the parent where supported. Profile reads remain serialized with publication. + - Determinate failure aborts the staged generation and reconstructs the old specification. Indeterminate persistence or non-preemptible startup timeout consumes staged state, invalidates the replacement token, and initiates controlled process shutdown. + - Profile replacement never detaches a live native worker. Final process exit may abandon a non-preemptible call only as an explicitly reported last resort, without claiming model memory or callbacks were released. + - Every named bound has capacity and capacity-plus-one tests owned by its defining module. The fixed bounds also retain 15 MiB per append, 30 seconds unfinalized audio, and two seconds acceptable audio lag. Bounds remain code policy until measurements justify configuration. + +## Testing Plan + +- Unit: + - Pin current batch and two-model behavior before moving code, including append-only accurate segments, replaceable provisional text, final authority, silence, segment ordering, and policy constants. + - Use role-specific scripted fake decoders to test thread confinement, exact request mode, guidance and history propagation, queue admission, cancellation, worker loss, factory error, panic, startup timeout classification, and partial-construction cleanup. + - Test LocalAgreement token comparison, exact whitespace ownership, hypothesis revision and duplicate suppression, final authority, stale epoch rejection, and revision overflow handling. + - Test little-endian PCM known bytes, Base64 boundaries, odd-byte appends, continuous resampling, commit flush, clear reset, duration calculation, append limit, short commit, and maximum buffered audio. + - Test immutable configuration snapshots, null-only turn detection, unsupported fields, optional client IDs, exact query validation, item lineage, reserve-before-detach, saturated retry, pending precommit failure, and one terminal outcome. + - Test generation admission races, fresh epoch after rollback, queued and running job guards, replacement cancellation at every await, replace against replace, replace against shutdown, determinate rollback, fatal token invalidation, and idempotent shutdown. +- Integration and end-to-end: + - Round-trip every canonical client and server fixture and drive sequence fixtures for ready creation, update, append, clear, commit acknowledgment, item creation, overlapping items, reversed completion, failure, and error correlation. + - Run native Whisper characterization before and after the engine and backend split using the same packaged runtime, model, audio, and expected transcript. + - Exercise batch physical-model selection, authentication, body limits, operational status, model listing, loopback and same-origin checks, and featureless Gateway compilation. + - Start Gateway with a scripted engine and verify the mounted Realtime route from connect through hypothesis and completion while legacy `/stt` still works. + - Drive Gateway and Workshop independently from the same canonical fixture sequences. Gateway tests inject scripted decoders without depending on `workshop-server`; Workshop relay tests inject an upstream fixture without depending on `gateway` or `gateway-stt`. The installed Windows package is the real dual-server acceptance. + - Drive the real Workshop dictation UI with fake media and worklet inputs, including second take, clear, overlapping finalization, hypothesis replacement, completion replacement, recoverable errors, status, and cleanup. + - Build packaged Windows binaries and perform the new-path microphone gate before legacy removal, then repeat final record, second-take, cancel, and permission or device-failure acceptance before completion. +- Regression, security, and performance: + - Enforce an exact workspace dependency allowlist for Gateway, all four STT crates, shared loopback, and Workshop server. Temporary rename-only engine edges to FFI and progress expire when the safe backend takes ownership; the Workshop edge expires at legacy removal. + - Enforce acyclic production-library module graphs for all four STT crates with compiler-resolved `cargo-modules` output, and line-count ceilings for every STT source module. Register each new module when created and never grow the legacy monolith before deleting it. + - Keep architecture checks in the default Gateway CI path so Workshop job exclusions cannot skip them. Prove Gateway-only builds no longer invoke Workshop UI tooling after legacy removal. + - Pin and wire Miri in a dedicated earlier step, then run pure ownership, queue, audio-state, agreement, and replacement-state targets under it. Keep sockets, dynamic FFI, native callbacks, and model loading on native CI. + - Test foreign, malformed, wrong-port, and mismatched loopback origins; missing Origin for native clients; trusted-loopback and strict-auth modes; duplicate or conflicting query parameters; and payload privacy. + - Test exact saturation boundaries for session, committed-item, interim, final, segment, mailbox, and cancellation-join capacities. Park native-equivalent fake work to prove bounded switch and shutdown behavior without sleeps. + - Capture first-provisional, first-agreed, endpoint, queue, compute, and final latency plus maximum queue depth and overload counts. Changes to constants require measured latency, memory, and transcript-quality evidence. +- Exit criteria: + - Formatting, linting with warnings denied, workspace tests, documentation tests, dependency audit, module architecture checks, both UI suites, Gateway and Workshop builds, featureless Gateway check, native Whisper tests, and synthetic full-path tests pass. + - Debt budgets are recorded before and after and every zero or cap target is met rather than deferred. + - Gateway-only builds contain no Workshop UI build edge, Gateway STT crates contain no Workshop dependency, and custom live STT routes and status messages are absent. + - Browser dictation and packaged Windows microphone acceptance pass before legacy removal and again at final completion. + +## Decision Record + +- Decisions: + - The operator requirement, "I want to make sure that whatever we build is generic because what you've done is you've, now you've tied Gateway to Workshop," settles the product boundary: Gateway exposes generic speech facts and Workshop remains only a consumer. + - The operator requirement, "I want you to prove that the technical debt's going to go down," settles measurable debt budgets, exact dependency enforcement, queue bounds, public API caps, and module ratchets as completion criteria. + - The operator requirement, "I want to have a well-defined boundary between all those syntax code and the gateway," settles the service, backend-neutral engine, safe Whisper backend, and unsafe-only FFI split. + - Use an OpenAI Realtime transcription subset for standard clients and one optional hypothesis snapshot extension for PromptForge's three-level live transcript. + - Name the extension negotiation value `item.input_audio_transcription.hypothesis` and its server event `conversation.item.input_audio_transcription.hypothesis`; these literals are version-one public wire commitments pinned by canonical fixtures. + - Pin the public contract to the OpenAI Realtime transcription schema retrieved 2026-09-05 from `https://developers.openai.com/api/docs/guides/realtime-transcription` and the generated OpenAI Node schema at commit `e228aaad`, especially `src/resources/realtime/realtime.ts` and `src/resources/realtime/client-secrets.ts`. This plan implements only the strict subset below; unknown fields are rejected on client events, unsupported upstream options are rejected as specified, and unsupported optional server fields are omitted. + - Standard client event `session.update`: required `type: "session.update"` and `session`; optional opaque client `event_id`. `session` requires `type: "transcription"` and may contain `audio` and `include`. `audio` may contain only `input`; `input` may contain `format: {"type":"audio/pcm","rate":24000}`, `noise_reduction: null`, `transcription` with `model: "realtime-transcribe"` and string `prompt`, and `turn_detection: null`. `include` may contain only `item.input_audio_transcription.hypothesis`. Omitted supported values retain their previous effective values. Non-null noise reduction or turn detection, `language`, logprobs, keywords, delay, another model, another format, an unknown include, and every other upstream field are rejected atomically. + - Standard client event `input_audio_buffer.append`: required `type: "input_audio_buffer.append"` and Base64 `audio`; optional opaque client `event_id`; no other fields. + - Standard client event `input_audio_buffer.commit`: required `type: "input_audio_buffer.commit"`; optional opaque client `event_id`; no other fields. + - Standard client event `input_audio_buffer.clear`: required `type: "input_audio_buffer.clear"`; optional opaque client `event_id`; no other fields. + - Standard server events `session.created` and `session.updated`: required `event_id`, the respective `type` literal, and complete effective `session`; no optional event fields. The effective session requires `id`, `object: "realtime.transcription_session"`, `type: "transcription"`, `audio: {"input":...}`, and `include`. Effective input requires the fixed PCM format object, `noise_reduction: null`, `transcription` with logical model and current prompt, and `turn_detection: null`; `include` is an array containing zero or one negotiated hypothesis value. `expires_at`, client secrets, modalities, output audio, language, logprobs, and other upstream session fields are omitted. + - Standard server event `input_audio_buffer.committed`: required `event_id`, `type: "input_audio_buffer.committed"`, provisional `item_id`, and `previous_item_id` as an opaque item ID or null; no optional fields. + - Standard server event `input_audio_buffer.cleared`: required `event_id` and `type: "input_audio_buffer.cleared"`; no optional fields. + - Standard server event `conversation.item.created`: required `event_id`, `type: "conversation.item.created"`, `previous_item_id` as an opaque item ID or null, and `item`. The item requires the same committed `id`, `type: "message"`, `status: "completed"`, `role: "user"`, and one `content` entry `{"type":"input_audio","transcript":null}`; audio bytes and all other conversation item variants or fields are omitted. + - Standard server event `conversation.item.input_audio_transcription.delta`: required `event_id`, its exact `type`, `item_id`, `content_index: 0`, and string `delta`; logprobs and other optional upstream fields are omitted. + - Standard server event `conversation.item.input_audio_transcription.completed`: required `event_id`, its exact `type`, `item_id`, `content_index: 0`, authoritative string `transcript`, and `usage` with `type: "duration"` and nonnegative numeric `seconds`; token usage, languages, logprobs, and other optional upstream fields are omitted. + - Standard server event `conversation.item.input_audio_transcription.failed`: required `event_id`, its exact `type`, `item_id`, `content_index: 0`, and `error`. The nested error requires string `type`, string `code`, and string `message`; optional `param` is a string or null. No transcript or usage is emitted. + - Standard server event `error`: required server `event_id`, `type: "error"`, and `error`. The nested error requires string `type`, string `code`, and string `message`; optional `param` is a string or null and optional `event_id` is the opaque client event ID or null. Client event IDs appear nowhere else. + - Custom server event `conversation.item.input_audio_transcription.hypothesis`: required `event_id`, its exact `type`, `item_id`, `content_index: 0`, monotonically increasing unsigned `revision`, full `transcript`, `finalized`, `agreed`, `tentative`, nonnegative `audio_start_ms`, and nonnegative `audio_end_ms`; no optional fields. The three text components concatenate byte-for-byte to `transcript`, the span is half-open, and this event is emitted only when its include value was negotiated. + - Server event, session, and item IDs are independently generated, nonempty opaque strings. A provisional item ID is allocated before commit and promoted unchanged; durable commit order alone determines `previous_item_id`; server IDs are never derived from or equal by contract to a client event ID. + - Preserve four STT crates. A second backend may implement the engine contracts later without changing HTTP or WebSocket endpoints. + - Share one serialized worker per loaded physical model while owning audio, agreement, history, finalization, and failures per input or committed item. + - Keep Workshop's Rust relay payload-opaque and derive all UI status locally from capture and protocol events. + - Use explicit generation admission, request and job guards, session epochs, and a two-phase replacement token instead of strong-reference counts or lock guards crossing awaits. + - Treat non-preemptible native startup timeout and indeterminate profile persistence as fatal controlled-shutdown cases rather than claiming unsafe rollback. + - Use Miri from pinned `nightly-2026-09-05` for pure STT ownership, queue, audio, agreement, and replacement tests. A dedicated workflow and Cargo feature-filtered targets establish this repository-selected UB interpreter before the final verification step. + - Architecture enforcement uses authoritative tools instead of interpreting full Rust syntax itself. Cargo metadata supplies workspace edges, the inherited compiler lint `unsafe_code = "forbid"` supplies unsafe isolation, `cargo-modules` 0.25.0 supplies expanded production-library module edges, and `cargo-public-api` 0.52.0 supplies effective public exports. A small Node 22 driver checks tool versions, module cycles, and public-root budgets; the Rust integration test owns only dependency policy, strict ceiling files, exact migration targets, and lint inheritance. Falsifier: either pinned tool disagrees with rustdoc or Cargo on an adversarial fixture, fails on a supported CI platform, or requires a newer compiler than Rust 1.89. + - Add two Gateway serving-log bookends because the operator identified an observability gap after the closed `gateway-logging-cli` run: the first file record identifies process version and launch, and the last record distinguishes clean or fatal exit from a killed process. This exception changes no CLI path, queue, sink, retention, rotation, redaction, subscriber ownership, or no-subscriber behavior. + - Run `cargo-modules` 0.25.0 and `cargo-public-api` 0.52.0 under the repository Rust 1.89 toolchain even when the surrounding CI job tests current stable. Cargo 1.98 removed the unstable metadata argument used by the pinned module tool, while Cargo 1.89 is the architecture contract's supported toolchain. The architecture driver owns this isolation so local and CI invocations cannot drift with ambient stable. + - Compile-only Workshop CI stages a real featureless Gateway binary under Tauri's target-suffixed `externalBin` name before compiling Workshop, then removes it. Release and nightly packaging continue staging the full release Gateway through their existing paths; no placeholder binary, checked-in artifact, or Tauri bundle change is accepted. + - The self-hosted Windows native runner must use Rust already provisioned under its service account. Add that account's Cargo bin directory to `PATH`, verify its `rustup`, `cargo`, and stable toolchain, and fail with a runner-provisioning error when any is absent. Do not run a rustup installer on the persistent runner or modify its default toolchain. + - Windows private-cache enforcement identifies the current process by its token SID rather than `USERNAME` and `USERDOMAIN`. Resolve the SID through the standard `whoami /user /fo csv /nh` interface, validate its canonical SID shape, and pass it to `icacls` with the required `*` SID prefix. Fail closed when identity resolution or ACL verification fails; never special-case or weaken privacy for service accounts. + - Session retirement tests wait on an explicit registry cleanup signal under a real deadline rather than counting scheduler yields. A slow CI scheduler must not make a correct bounded cleanup test fail, and a missing cleanup signal must still time out visibly. + - Gateway host-specific declarations and lint expectations exist only on the platforms that use them. Non-Windows builds must not compile the Windows manifest constant or carry an unfulfilled unsafe-code expectation. + - Installed-package STT acceptance uses a local unsigned NSIS build with updater artifacts disabled only through the Tauri command-line configuration override. Functional acceptance does not require distribution signing. Repository release configuration and release CI signing remain unchanged, and the acceptance record must state that signing was not tested. + - Agent input cannot enter the built-in chat while no model is selected. The UI keeps text editable, blocks submission, and names the required model selection; if selection becomes invalid after submission, `models.chat` emits the existing model-turn failure observation before Lua `pcall` returns to the next input. A local model-binding error must never appear as a silent tool result with no Gateway request. + - Workshop startup must converge after it launches beside a still-loading Gateway. While Gateway remains reachable, an empty model catalog or absent selection triggers bounded refresh retries; a failed initial Realtime socket reconnects under bounded backoff; completed imported Gateway progress detaches by upstream operation even though the SSE stream remains open. Every retry and imported operation is canceled on shutdown. + - A precommit hypothesis carries the provisional item ID that commit later promotes unchanged. The browser binds the first valid hypothesis for the active uncommitted take to that ID immediately, renders subsequent snapshots live, and requires the commit acknowledgment to confirm the same ID. Unknown hypotheses with no active take never mutate text. +- Rejected alternatives: + - Keeping Workshop status frames, headers, guards, or types in Gateway because it preserves the forbidden product dependency. + - Exposing the Gateway key to the webview because it expands browser credential exposure. + - Adding STT wire types to `shared-protocol` because the relay does not parse payloads and shared fixtures provide sufficient Rust and TypeScript compatibility. + - Merging FFI and safe backend code because it expands the unsafe-capable surface and obscures ownership. + - Loading model copies per client because it violates the memory and worker-count constraints. + - Using strong-reference counts for quiescence because unrelated references do not prove admitted work ownership. + - Detaching native workers during profile replacement because live model memory and callbacks would outlast the generation while replacement proceeds. + - Emitting standard item deltas before item creation because standard clients cannot reconcile an unpublished item. + - Blocking or dropping an authoritative final segment on queue pressure because either can stall socket ownership or produce a false completed transcript. + - Expanding algorithm scope during the boundary migration because structural and protocol changes need a stable behavioral baseline. +- Assumptions, risks, and notes: + - The current packaged Whisper runtime, model pair, and native JFK fixture remain available for characterization. Missing native assets block equivalence claims. + - Native model loading and decode may hang inside code Rust cannot preempt. Bounded control-plane response therefore sometimes requires refusing replacement or terminating the process rather than reclaiming the thread. + - Reconstructing the old generation can fail after a determinate staged failure; this is reported as rollback failure and leaves speech unavailable. + - OpenAI's Realtime schema can evolve. Canonical fixtures define the implemented subset, and compatibility claims must be rechecked when the upstream contract changes. + - The custom hypothesis include value is intentionally outside official SDK closed enums and may require extension-aware client code. + - A WebSocket server cannot prove peer receipt without acknowledgment. The no-loss guarantee covers internal accepted terminal events while the connection remains writable. + - Manual microphone acceptance is a real release gate and cannot be replaced by synthetic audio alone. + +## Project survey + +- Build commands: + - Prerequisite: Rust 1.89 or later and Node.js 22, then `npm ci --prefix crates/workshop-server/ui` and `npm ci --prefix crates/gateway-config-ui/ui` once per checkout. + - `cargo build` builds the default workspace member, `gateway`, including the default config UI and STT features. + - `cargo build -p workshop` builds the Tauri desktop product and its in-process `workshop-server`. + - The two UI bundles build independently with `npm run build` in `crates/workshop-server/ui` and `crates/gateway-config-ui/ui`; Cargo build scripts place generated bundles in `OUT_DIR`. +- Focused test command patterns: + - Rust unit or named test: `cargo test -p `. + - Rust integration harness: `cargo test -p --test it `. Current Gateway, `gateway-stt`, and `workshop-server` integration suites use `tests/it/main.rs` as the harness and responsibility-named modules below it. + - STT package gates: `cargo test -p gateway-transcribe`, `cargo test -p gateway-stt --test it`, `cargo test -p gateway-whisper-ffi`, `cargo test -p gateway`, and `cargo test -p workshop-server --test it`. + - Native Whisper tests are ignored by default and use the same package command with `-- --ignored`; they require `PROMPTFORGE_WHISPER_LIBRARY` plus the gitignored `gateway-transcribe/tests/fixtures/ggml-tiny.en.bin` and `jfk.wav`, or `PROMPTFORGE_WHISPER_MODEL` and `PROMPTFORGE_WHISPER_AUDIO` overrides. + - Workshop UI focused tests run directly from `crates/workshop-server/ui`, for example `node --test test/stt-stream.mjs`; the complete UI discovery command is the package's `npm test`. + - Config UI focused tests run from `crates/gateway-config-ui/ui` with `node --test src/.test.mjs`; `npm test` runs its complete discovered suite. +- Full-suite test commands: + - Rust: `cargo test --workspace`. CI splits this into `cargo test --locked --workspace --exclude workshop --exclude workshop-server --all-features` on Linux and `cargo test --locked -p workshop -p workshop-server` on Windows. + - Workshop UI: run `npm run typecheck`, then `npm run build`, then `npm test` as separate commands in `crates/workshop-server/ui`. + - Config UI: run `npm run typecheck`, then `npm run build`, then `npm test` as separate commands in `crates/gateway-config-ui/ui`; its tests import the built `dist/app.js`, so build precedes test. +- Linter and formatter commands: + - Rust formatting: `cargo fmt --all --check`. + - Rust linting: `cargo clippy --workspace --all-targets --all-features -- -D warnings`; CI excludes `workshop` and `workshop-server` in the Linux job and lints those two packages on Windows. + - Documentation gates: `cargo test --workspace --all-features --doc` and then `$env:RUSTDOCFLAGS='-D warnings'; cargo doc --workspace --no-deps --all-features`. + - Feature boundary gate: `cargo check -p gateway --no-default-features`. + - Workshop UI layering and types: `npm run typecheck`, which runs `tsc --noEmit` and `check-layers.mjs`. Config UI uses `npm run typecheck` and runs `check-layers.mjs` through `npm test`. Neither UI package defines a standalone formatter command. +- Test placement and naming: + - Rust unit tests are colocated in source modules under `#[cfg(test)]`; async tests use `#[tokio::test]`. + - Cross-module and socket tests live under `tests/it/`, with shared fixtures in `tests/common/`. Test function names are lower snake case behavior statements. + - Native and large-download tests are explicitly `#[ignore]` and name their required fixture or live dependency. + - Workshop UI tests are either `ui/test/**/*.mjs` or colocated `ui/src/**/*.test.mjs`. Names are plain English behavior statements, and disposable-owning tests use `test/helpers/leak-check.mjs`. +- Directory map: + - `.cargo/` contains repository Cargo configuration; `.github/` contains CI, release, nightly, native Whisper, and guide workflows plus reusable actions. + - `crates/` is the product and library workspace. Current Gateway speech code is in `gateway-stt`, `gateway-transcribe`, and `gateway-whisper-ffi`; the planned `gateway-stt-engine` and `gateway-stt-backend-whisper` directories do not yet exist. + - Gateway product crates are `gateway`, `gateway-config`, `gateway-config-ui`, `gateway-local`, `gateway-logging`, `gateway-routing`, `gateway-stt`, `gateway-transcribe`, `gateway-web-search`, and `gateway-whisper-ffi`. + - Workshop product crates are `workshop` and `workshop-server`; the browser application is under `crates/workshop-server/ui`. + - Cross-product substrate is in `shared-loopback`, `shared-progress`, `shared-protocol`, `shared-sidecar`, and the non-Rust `shared-ui` package. + - PromptForge library crates use the `promptforge-*` prefix, while `build-*` crates are compile-time and CI tooling. + - `design/` holds design material, `guide/` holds mdBook documentation, `images/` holds repository media, `prompts/` holds prompt programs, `tools/` holds repository tooling, and `vibe/` holds execution plans and `vibe/archdoc.md`. +- Current STT paths and component boundaries: + - `crates/gateway/src/lib.rs` mounts authenticated `POST /v1/audio/transcriptions`, `/stt`, and `/stt/capability`; `crates/gateway/src/runner.rs` owns STT startup and profile-switch calls. + - `crates/gateway-stt/src/runtime.rs` provisions artifacts and owns active engine publication, `src/api.rs` handles OpenAI multipart batch transcription, and the 850-line `src/stt.rs` owns the Workshop-specific streaming socket, take state, interim loop, finalization, and status frames. Its integration characterization is in `tests/it/stt.rs`. + - `gateway-stt` currently depends on `gateway-transcribe`, `gateway-local`, `gateway-config`, `shared-progress`, and `workshop-server`. This is the current boundary to dismantle, not the target boundary already described above. + - `crates/gateway-transcribe/src/` is the current engine package: `engine.rs` and `worker.rs` own model workers, `final_pass.rs` owns accurate-pass state, `segment.rs` owns segmentation, `prompt.rs` owns Whisper prompt fitting, `slot.rs` owns active engine publication, and `lib.rs` owns silence and window policy. It currently depends directly on `gateway-whisper-ffi`. + - `crates/gateway-whisper-ffi/src/` is the runtime-loaded ABI leaf. `library.rs`, `context.rs`, `params.rs`, `raw.rs`, and `log.rs` contain the only STT native loading, pointer ownership, ABI layout, and unsafe calls. + - `crates/gateway-config/src/config/stt.rs` owns STT model catalog entries and roles. Capture tuning still lives in `crates/gateway-config/src/config/workshop.rs` as `WorkshopSttConfig` under `[workshop.stt]`. + - `crates/workshop-server/src/routes/stt.rs` currently proxies capability and relays `/stt`; `src/gateway.rs` owns the authenticated upstream socket connector. The relay currently parses private `workshop_status` frames rather than remaining payload opaque. + - `crates/workshop-server/ui/src/ui/stt.ts` currently owns 16 kHz `f32` microphone capture, old start/stop framing, transcript insertion, and local capture errors; `ui/test/stt-stream.mjs` characterizes generation handling. + - `vibe/archdoc.md` is the architecture anchor. It defines gateway, Workshop UI, executor, store, Lua boundary, and shared-substrate components, with dependencies flowing toward gateway, store, and shared substrate. Relevant invariants include gateway-only credential ownership, Workshop cross-site and WebSocket-origin rejection, descendant cancellation, service-owned connection records, explicit endpoint readiness, and config apply publication consistency. +- Visible conventions: + - Crate prefixes encode product ownership. A shared dependency must live in a `shared-*` crate, and build-only tooling in `build-*`. + - Cargo features gate real constraints only. Gateway `local`, `web-search`, `config-ui`, and `stt` features are additive and default on; the featureless Gateway check must remain green. + - Rust modules are private by default with deliberate crate-root re-exports. Every public item requires rustdoc, public fields are avoided, libraries use typed errors, and behavior changes carry tests. + - Runtime paths do not compile native code. Whisper is loaded from packaged runtime artifacts, worker threads own native contexts, and async callers exchange owned buffers through channels and oneshots. + - Unsafe code, C symbols, ABI layouts, and raw Whisper pointers stay in `gateway-whisper-ffi`; each unsafe block has an adjacent `SAFETY` justification and pointers stay behind `Drop`-owning wrappers. + - Workshop server route groups expose `routes(state) -> Router`; `app.rs` composes them. One task owns each ordinary socket, request/session errors are values, and in-process tests use `Router::oneshot` or spawn fixtures. + - Workshop UI imports flow `ui -> services -> base`; `main.ts` is the composition root. The rule is enforced by `check-layers.mjs` during build, typecheck, and Cargo bundling. + - Generated UI bundles are never checked in. `crates/workshop-server/module-ceilings.toml`, enforced by `cargo test -p workshop-server --test it`, is the only current source-module size ratchet. +- Rules manifest: + - `AGENTS.md` governs the repository root. + - `crates/gateway/AGENTS.md` governs `crates/gateway/`. + - `crates/gateway-config/AGENTS.md` governs `crates/gateway-config/`. + - `crates/gateway-local/AGENTS.md` governs `crates/gateway-local/`. + - `crates/gateway-logging/AGENTS.md` governs `crates/gateway-logging/`. + - `crates/gateway-routing/AGENTS.md` governs `crates/gateway-routing/`. + - `crates/gateway-stt/AGENTS.md` governs `crates/gateway-stt/`. + - `crates/gateway-transcribe/AGENTS.md` governs `crates/gateway-transcribe/`. + - `crates/gateway-web-search/AGENTS.md` governs `crates/gateway-web-search/`. + - `crates/gateway-whisper-ffi/AGENTS.md` governs `crates/gateway-whisper-ffi/`. + - `crates/promptforge/AGENTS.md` governs `crates/promptforge/`. + - `crates/promptforge-agent/AGENTS.md` governs `crates/promptforge-agent/`. + - `crates/promptforge-core/AGENTS.md` governs `crates/promptforge-core/`. + - `crates/promptforge-core-support/AGENTS.md` governs `crates/promptforge-core-support/`. + - `crates/promptforge-lua/AGENTS.md` governs `crates/promptforge-lua/`. + - `crates/promptforge-model-client/AGENTS.md` governs `crates/promptforge-model-client/`. + - `crates/promptforge-parser/AGENTS.md` governs `crates/promptforge-parser/`. + - `crates/promptforge-store/AGENTS.md` governs `crates/promptforge-store/`. + - `crates/promptforge-tools/AGENTS.md` governs `crates/promptforge-tools/`. + - `crates/promptforge-web-search/AGENTS.md` governs `crates/promptforge-web-search/`. + - `crates/promptforge-webfetch/AGENTS.md` governs `crates/promptforge-webfetch/`. + - `crates/shared-loopback/AGENTS.md` governs `crates/shared-loopback/`. + - `crates/shared-progress/AGENTS.md` governs `crates/shared-progress/`. + - `crates/shared-protocol/AGENTS.md` governs `crates/shared-protocol/`. + - `crates/shared-sidecar/AGENTS.md` governs `crates/shared-sidecar/`. + - `crates/shared-ui/AGENTS.md` governs `crates/shared-ui/`. + - `crates/workshop/AGENTS.md` governs `crates/workshop/`. + - `crates/workshop/icons/AGENTS.md` additionally governs `crates/workshop/icons/`. + - `crates/workshop-server/AGENTS.md` governs `crates/workshop-server/`. + - `crates/workshop-server/ui/AGENTS.md` additionally governs `crates/workshop-server/ui/`. + +## Execution Instructions + +Use Windows PowerShell 5.1. Every command below has an explicit working directory and runs separately, so no shell state or success chaining is assumed. Before Step 1, run `npm ci` separately in `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui` and `C:\Users\Vinnie\cursor\promptforge\crates\gateway-config-ui\ui`. Before native tests, extract `https://github.com/cppalliance/promptforge/releases/download/whisper-lib-b4938/whisper-b4938-windows-x86_64-cuda.zip` to `C:\Users\Vinnie\cursor\promptforge\local\stt-fixtures\`; place `ggml-tiny.en.bin` from `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin` and `jfk.wav` from `https://github.com/ggerganov/whisper.cpp/raw/master/samples/jfk.wav` in the current engine fixture directory; and let each native command set `$env:PATH` and `$env:PROMPTFORGE_WHISPER_LIBRARY` itself. Begin from a clean worktree and green baseline. Each step is one commit; formatting, warnings-denied linting for touched packages, and its listed commands must pass before the next step. When architecture changes invalidate an `AGENTS.md` rule, delete that text in the same commit and add only a minimal replacement when the new boundary would otherwise be unenforced. Do not restate root rules or this design plan in nested rule files. + +The architecture harness enforces exact workspace-package edges across normal, development, and build dependencies, ignoring self-dependencies. Phase A, Steps 7 through 24: `gateway -> {gateway-config,gateway-config-ui,gateway-local,gateway-logging,gateway-routing,gateway-stt,gateway-web-search,promptforge-core,shared-loopback,shared-progress,shared-protocol,shared-sidecar}`; `gateway-stt -> {gateway-config,gateway-local,gateway-stt-backend-whisper,gateway-stt-engine,shared-progress,workshop-server}`; `gateway-stt-engine -> {}`; `gateway-stt-backend-whisper -> {gateway-stt-engine,gateway-whisper-ffi,shared-progress}`; `gateway-whisper-ffi -> {}`; `shared-loopback -> {}`; `workshop-server -> {build-ui,promptforge-agent,promptforge-core-support,promptforge-model-client,promptforge-store,promptforge-tools,shared-progress,shared-sidecar}`. Phase B, Steps 27 through 38, adds only `workshop-server -> shared-loopback`. Final Phase C, Step 39 onward, is Phase B with only `gateway-stt -> workshop-server` removed. No normal or development edge from `gateway` to `workshop-server`, and no new such edge from `gateway-stt`, may be added; the current `gateway-stt -> workshop-server` edge is solely a temporary removal target. Beginning with Step 7, every later step touching an STT source file, manifest, crate-root export, or ceiling runs `node tools/check-stt-architecture.mjs` followed by the unfiltered Rust architecture test. + +### Step 1: Characterize current speech behavior [completed] + +- Artifacts: split `crates/gateway-stt/tests/it/stt.rs` into `tests/it/batch.rs` and `tests/it/legacy_stream.rs`, extend `tests/common/mod.rs`, and register both modules in `tests/it/main.rs`. +- Scope: pin batch physical-model selection, current two-model streaming, policy constants, segment order, final authority, and cross-client failure behavior without changing production code. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it` +- Consumes and gates: consumes the green baseline; these assertions must be preserved by replacement fixtures before legacy tests retire. + +### Step 2: Pin the pre-rename native target [completed] + +- Artifacts: create `crates/gateway-transcribe/tests/native_whisper.rs` and preserve `tests/fixtures/ggml-tiny.en.bin`, `tests/fixtures/jfk.wav`, and their ignore rule. +- Scope: pin packaged-runtime loading, transcript text, decode policy, prompt behavior, and cleanup in one explicit ignored integration target. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `$fixture=(Resolve-Path 'local\stt-fixtures').Path; $env:PATH="$fixture;$env:PATH"; $env:PROMPTFORGE_WHISPER_LIBRARY=(Resolve-Path 'local\stt-fixtures\whisper.dll').Path; cargo test -p gateway-transcribe --test native_whisper -- --ignored` +- Consumes and gates: consumes Step 1 and the named external fixtures; the same assets and expected transcript gate Steps 4 and 6. + +### Step 3: Freeze canonical Realtime fixtures [completed] + +- Artifacts: create `crates/gateway-stt/tests/fixtures/realtime/*.json`, `tests/it/realtime_fixtures.rs`, and `crates/workshop-server/ui/test/realtime-wire-fixtures.mjs`; register `realtime_fixtures` in `crates/gateway-stt/tests/it/main.rs`. +- Scope: encode every event, effective session, error, usage, ID, hypothesis, and valid or invalid sequence from the Decision Record without mounting a route. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it realtime_fixtures` + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `node --test test/realtime-wire-fixtures.mjs` +- Consumes and gates: consumes the complete 2026-09-05 wire contract; fixture parity gates every wire implementation and consumer. + +### Step 4: Rename the engine without changing APIs [completed] + +- Artifacts: rename `crates/gateway-transcribe/` to `crates/gateway-stt-engine/`; update root `Cargo.toml`, `Cargo.lock`, root `.gitignore`, the moved `AGENTS.md`, `crates/gateway-stt/Cargo.toml`, `crates/gateway-stt/AGENTS.md`, imports, and verified textual references in `tools/document.md`; do not touch `.github/workflows/whisper-lib.yml`, which has no crate reference. +- Scope: preserve behavior and current APIs, move fixtures and the existing engine rules with the crate, add no compatibility crate, and compile every current reverse consumer. This mechanical commit changes names only; Step 6 removes rules invalidated by the new boundary. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt-engine` + - `C:\Users\Vinnie\cursor\promptforge`: `$fixture=(Resolve-Path 'local\stt-fixtures').Path; $env:PATH="$fixture;$env:PATH"; $env:PROMPTFORGE_WHISPER_LIBRARY=(Resolve-Path 'local\stt-fixtures\whisper.dll').Path; cargo test -p gateway-stt-engine --test native_whisper -- --ignored` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway` +- Consumes and gates: consumes Steps 1 and 2; all renamed consumers and the post-rename native target must pass in this commit. + +### Step 5: Move take ownership into gateway-stt [completed] + +- Artifacts: create `crates/gateway-stt/src/take.rs`, move segmentation and LocalAgreement state from `src/stt.rs` and `gateway-stt-engine/src/segment.rs` into gateway-stt modules, make `gateway-stt-engine/src/final_pass.rs` and `src/worker.rs` execute stateless decode jobs, and adapt the legacy stream in `gateway-stt/src/stt.rs` to the single `take::Take`. +- Scope: `Take` exclusively owns guidance, finalized history, segment aggregation, completion, and failure; remove engine reset channels and accumulated transcript state, create no engine `FinalTake`, and update every engine API consumer in the same commit. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt-engine` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it legacy_stream` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway` +- Consumes and gates: consumes characterization and the renamed engine; legacy ownership isolation gates Realtime reuse of `take.rs`. + +### Step 6: Extract contracts and safe backend atomically [completed] + +- Artifacts: create `gateway-stt-engine/src/decoder.rs` and `policy.rs`; create `crates/gateway-stt-backend-whisper/{Cargo.toml,AGENTS.md,src/lib.rs,src/config.rs,src/model.rs,src/prompt.rs,tests/native_whisper.rs}`; update root manifests, `gateway-stt` manifest and runtime, all imports, crate-root exports, `crates/gateway-stt/AGENTS.md`, and the moved `crates/gateway-stt-engine/AGENTS.md`. +- Scope: replace `EngineConfig` and constructors once, update every current gateway-stt and Gateway consumer in this commit, expose only the seven engine items and two backend items, and leave no FFI or prompt policy in the engine and no compatibility shim. Delete the moved engine rules that assign Whisper loading, prompt fitting, segmentation, take state, or FFI integration to the engine; retain only backend-neutral bounded-worker constraints. Reduce the service rules to facade, lifecycle, batch, Realtime, and sole take ownership. The new backend rule file contains only safe Whisper construction, prompt and decode policy, progress, and the prohibition on unsafe or host types. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt-engine` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt-backend-whisper` + - `C:\Users\Vinnie\cursor\promptforge`: `$fixture=(Resolve-Path 'local\stt-fixtures').Path; $env:PATH="$fixture;$env:PATH"; $env:PROMPTFORGE_WHISPER_LIBRARY=(Resolve-Path 'local\stt-fixtures\whisper.dll').Path; cargo test -p gateway-stt-backend-whisper --test native_whisper -- --ignored` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-whisper-ffi` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway` +- Consumes and gates: consumes Step 5 stateless jobs; matching native output and green reverse consumers gate bounded workers. + +### Step 7: Establish exact architecture ratchets [completed] + +- Artifacts: create `tools/check-stt-architecture.mjs`; reduce `crates/gateway-stt/tests/it/architecture.rs` to Cargo metadata edge policy, strict ceiling and migration policy, and inherited lint checks; register it in `tests/it/main.rs`; create `module-ceilings.toml` in all four STT crates; remove the unused `syn` workspace and development dependencies; and add pinned tool installation plus both gates to the normal CI job. +- Scope: enforce the stated temporary and final workspace-edge allowlists through Cargo metadata, unsafe isolation through the existing compiler lint, production-library module cycles through filtered `cargo-modules` 0.25.0 DOT output collapsed to module nodes, effective public-root budgets through `cargo-public-api` 0.52.0 output, and current ceilings through strict policy files. The driver rejects wrong tool versions and malformed output. Temporary exceptions name their removal step. Do not retain source-level Rust syntax analysis. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo modules --version` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo public-api --version` + - `C:\Users\Vinnie\cursor\promptforge`: `node tools/check-stt-architecture.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `node --test tools/check-stt-architecture.test.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it architecture` +- Consumes and gates: consumes Step 6 final crate topology; the unfiltered command becomes mandatory after every later STT edit. + +### Step 8: Bound workers and expose scripted tests [completed] + +- Artifacts: revise engine `worker.rs`, `engine.rs`, `error.rs`, and manifest; add `test-fixtures` scripted `ModelFactory` and `Decoder`; forward test features in backend and `gateway-stt` manifests; add Gateway development wiring and `crates/gateway/src/test_support.rs` injection without a new production facade type. +- Scope: enforce `INTERIM_JOB_CAPACITY = 8` and `FINAL_JOB_CAPACITY = 8`, capacity and capacity-plus-one admission, cancellation, panic, factory failure, startup outcomes, cleanup, thread confinement, and non-detaching idempotent shutdown. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt-engine --features test-fixtures` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --features test-fixtures` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway` + - `C:\Users\Vinnie\cursor\promptforge`: `node tools/check-stt-architecture.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it architecture` +- Consumes and gates: consumes Step 7; scripted injection gates deterministic lifecycle and socket tests without widening the six-type production facade. + +### Step 9: Select and wire Miri [completed] + +- Artifacts: create `.github/workflows/stt-miri.yml`, add Miri-safe pure worker tests under the engine `test-fixtures` feature, and document exclusions beside unsupported socket and FFI tests. +- Scope: pin `nightly-2026-09-05`, run only pure ownership and queue targets, and establish the repository-selected UB interpreter before service state exists. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `rustup toolchain install nightly-2026-09-05 --component miri` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo +nightly-2026-09-05 miri setup` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo +nightly-2026-09-05 miri test -p gateway-stt-engine --features test-fixtures miri_` + - `C:\Users\Vinnie\cursor\promptforge`: `node tools/check-stt-architecture.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it architecture` +- Consumes and gates: consumes Step 8 scripted workers; later pure service targets join this pinned workflow. + +### Step 10: Migrate canonical configuration and every consumer [completed] + +- Artifacts: replace `WorkshopSttConfig` with `SttPipelineConfig` across `gateway-config/src/config/{workshop.rs,stt.rs,tests.rs,tests/schema.rs,tests/serialize.rs,tests/validation.rs}`, `config.rs`, and `lib.rs`; update `gateway-stt/src/runtime.rs`; Gateway warnings and tests in `src/runner.rs`; `gateway.local.example.toml`; `crates/gateway-config/README.md`; `crates/gateway/README.md`; `crates/gateway/AGENTS.md`; config UI `services/config-store.ts`, `views/settings-view.ts`, `views/settings-sections.test.mjs`; source guides `guide/src/gateway/05-speech.md`, `guide/src/gateway/10-serving-and-observing.md`, `guide/src/workshop/01-application.md`, and `guide/src/workshop/07-voice.md`; generated `guide/src/SUMMARY.md`, `guide/src/gateway/index.md`, `guide/src/workshop/index.md`, `guide/promptforge-gateway-guide.md`, and `guide/promptforge-workshop-guide.md`. +- Scope: accept legacy `[workshop.stt]` only during parsing when `[stt]` is absent, reject both, serialize only `[stt]`, update all direct consumers in one commit, and provide no type or accessor alias. In `crates/gateway/AGENTS.md`, delete the stale statement that `[workshop.stt]` remains live and do not replace it with configuration detail already enforced by `gateway-config`. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-config` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway` + - `C:\Users\Vinnie\cursor\promptforge\crates\gateway-config-ui\ui`: `npm run build` + - `C:\Users\Vinnie\cursor\promptforge\crates\gateway-config-ui\ui`: `node --test src/views/settings-sections.test.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo run -p build-user-guide` + - `C:\Users\Vinnie\cursor\promptforge`: `node tools/check-stt-architecture.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it architecture` +- Consumes and gates: consumes Step 6 backend configuration; canonical schema and generated documentation gate the facade. + +### Step 11: Add audio ingestion and shared PCM bytes [completed] + +- Artifacts: add `base64 = "0.22"` to root `Cargo.toml` and `base64.workspace = true` to `crates/gateway-stt/Cargo.toml`; create `gateway-stt/src/audio.rs` and language-neutral `tests/fixtures/audio/pcm16le-24khz.json`; update ceilings. +- Scope: review Base64 license, Rust 1.89 support, and transitive tree before acceptance; implement endian decoding, Base64 boundaries, odd-byte state, continuous 24 kHz to 16 kHz conversion, flush, reset, durations, and size bounds. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `$env:RUSTUP_TOOLCHAIN='stable'; cargo install cargo-deny --locked` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo tree -p gateway-stt -i base64@0.22.1` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo deny check` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt audio` + - `C:\Users\Vinnie\cursor\promptforge`: `node tools/check-stt-architecture.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it architecture` +- Consumes and gates: consumes Step 10 tuning and Step 7 budgets; dependency review and byte fixtures gate Rust and JavaScript audio consumers. + +### Step 12: Implement the private wire [completed] + +- Artifacts: create `gateway-stt/src/realtime/{mod.rs,wire.rs,query.rs}`, bind them to canonical fixtures, update ceilings, and keep every type private. +- Scope: implement only the Decision Record subset, atomic updates, strict unknown-field rejection, opaque IDs, exact errors and usage, and query validation without opening a socket. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt realtime::wire` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it realtime_fixtures` + - `C:\Users\Vinnie\cursor\promptforge`: `node tools/check-stt-architecture.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it architecture` +- Consumes and gates: consumes Steps 3 and 11; exact fixture round trips gate session state. + +### Step 13: Isolate architecture tools from ambient stable [completed] + +- Artifacts: update `tools/check-stt-architecture.mjs`, `tools/check-stt-architecture.test.mjs`, and the architecture-tool setup in `.github/workflows/ci.yml`. +- Scope: install Rust 1.89 alongside the job's current stable toolchain, then make every `cargo-modules` 0.25.0 and `cargo-public-api` 0.52.0 child run with `RUSTUP_TOOLCHAIN=1.89` while leaving formatting, Clippy, tests, and documentation on stable. Preserve pinned versions and fail closed when Rust 1.89 or either tool is absent. Add child-environment tests proving ambient Cargo 1.98 cannot leak into architecture commands. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `node --test tools/check-stt-architecture.test.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `$env:RUSTUP_TOOLCHAIN='stable'; node tools/check-stt-architecture.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it architecture` +- Consumes and gates: this repairs the reproducible Linux CI failure where Cargo 1.98 rejects the pinned module tool's removed `--lockfile-path` metadata argument. It is independent of Realtime behavior and must pass before later steps rely on the architecture driver. + +### Step 14: Stage Workshop sidecars in compile CI [completed] + +- Artifacts: add `tools/stage-gateway-sidecar.mjs` and `tools/stage-gateway-sidecar.test.mjs`; update only the `check-workshop` and `check-workshop-linux` jobs in `.github/workflows/ci.yml`. +- Scope: before Workshop Clippy, tests, or build, compile `gateway` without default features and copy the real executable to `crates/workshop/binaries/promptforge-gateway-` for the current Windows or Linux host. Remove the staged file after the Workshop commands. Keep the directory gitignored, reject missing or mismatched source binaries, and do not modify base Tauri configuration, release packaging, nightly packaging, or shipped sidecar features. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `node --test tools/stage-gateway-sidecar.test.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo build --locked -p gateway --no-default-features` + - `C:\Users\Vinnie\cursor\promptforge`: `node tools/stage-gateway-sidecar.mjs stage --target x86_64-pc-windows-msvc --source target/debug/promptforge-gateway.exe` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo check --locked -p workshop` + - `C:\Users\Vinnie\cursor\promptforge`: `node tools/stage-gateway-sidecar.mjs remove --target x86_64-pc-windows-msvc` +- Consumes and gates: this repairs the same missing `externalBin` failure observed as `promptforge-gateway-x86_64-pc-windows-msvc.exe` on Windows and `promptforge-gateway-x86_64-unknown-linux-gnu` on Linux. Target-mapping tests cover both hosts, and the existing CI clean-tree checks remain green. + +### Step 15: Own sessions and uncommitted input [completed] + +- Artifacts: create `gateway-stt/src/realtime/{session.rs,input.rs,registry.rs}`, `tests/it/realtime_session.rs`, register it in `tests/it/main.rs`, and update ceilings and Miri workflow filters. +- Scope: enforce `MAX_ACTIVE_REALTIME_SESSIONS = 8` with no wait queue and immediate ninth rejection, `SESSION_CANCEL_JOIN_CAPACITY = 8`, immutable first-append snapshots, clear, resampler reset, interim epochs, and capacity and capacity-plus-one tests. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it realtime_session` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo +nightly-2026-09-05 miri test -p gateway-stt --features test-fixtures miri_` + - `C:\Users\Vinnie\cursor\promptforge`: `node tools/check-stt-architecture.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it architecture` +- Consumes and gates: consumes scripted decoding, audio, wire, and the sole `take.rs`; snapshot and cancellation isolation gate commit. + +### Step 16: Finalize committed items independently [completed] + +- Artifacts: create `gateway-stt/src/realtime/{item.rs,result_mailbox.rs}`, extend `src/take.rs` and `tests/it/realtime_session.rs`, and update ceilings and Miri targets. +- Scope: enforce `MAX_COMMITTED_ITEMS_PER_SESSION = 4`, `SESSION_RESULT_CAPACITY = 16` plus one reserved terminal slot per item, one replaceable hypothesis slot per item, and `FINAL_SEGMENT_CAPACITY = 4` per item; add capacity and capacity-plus-one, durable lineage, reversed completion, saturated retry, pending failure, and one-terminal tests. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it realtime_session` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo +nightly-2026-09-05 miri test -p gateway-stt --features test-fixtures miri_` + - `C:\Users\Vinnie\cursor\promptforge`: `node tools/check-stt-architecture.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it architecture` +- Consumes and gates: consumes Step 15; complete item ownership gates facade replacement and generation quiescence. + +### Step 17: Use preinstalled Rust on the native runner [completed] + +- Artifacts: update only the `native-whisper` job in `.github/workflows/stt-miri.yml` and add `tools/check-stt-native-workflow.test.mjs`. +- Scope: remove `dtolnay/rust-toolchain@stable` from the self-hosted Windows job. Before Cargo caching or native tests, resolve the service account's existing `.cargo\bin`, require `rustup.exe` and `cargo.exe`, append that directory to `GITHUB_PATH`, and verify the preinstalled stable toolchain without installing rustup, creating proxy links, changing the default toolchain, or enabling self-update. Keep the hosted Linux Miri job and all native fixture or test commands unchanged. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `node --test tools/check-stt-native-workflow.test.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `$cargoBin=Join-Path $env:USERPROFILE '.cargo\bin'; $rustup=Join-Path $cargoBin 'rustup.exe'; $cargo=Join-Path $cargoBin 'cargo.exe'; if (-not (Test-Path $rustup -PathType Leaf) -or -not (Test-Path $cargo -PathType Leaf)) { throw 'self-hosted runner Rust is not provisioned' }; & $rustup toolchain list; & $cargo '+stable' '--version'` +- Consumes and gates: this repairs the self-hosted `NetworkService` failure where the toolchain action did not find the existing Cargo bin directory, attempted to reinstall rustup, and collided with an existing `rust-analyzer.exe`. The source test must prove the native job performs preflight before cache and contains no Rust installer action, while the hosted Miri job still installs its pinned nightly. + +### Step 18: Replace runtime and route APIs atomically [completed] + +- Artifacts: replace `gateway-stt/src/runtime.rs` with `service.rs`, `artifacts.rs`, `generation.rs`, `status.rs`, and `model.rs`; rename `api.rs` to `batch.rs`; replace `SttRuntime`, `SttState`, free route APIs, and old exports in `lib.rs`; update `gateway/src/{lib.rs,runner.rs,test_support.rs}` and all gateway-stt tests and common fixtures in the same commit. +- Scope: expose only `SpeechService` plus five supporting types, preserve batch and temporary legacy routes through methods, publish one complete snapshot, and retain test-only scripted construction behind `test-fixtures`. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --features test-fixtures` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo check -p gateway --no-default-features` + - `C:\Users\Vinnie\cursor\promptforge`: `node tools/check-stt-architecture.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it architecture` +- Consumes and gates: consumes Steps 10 and 16; every current reverse consumer compiles and tests in this API-changing commit. + +### Step 19: Resolve Windows cache ownership by SID [completed] + +- Artifacts: update `gateway-local/src/artifacts/confine.rs`, its focused tests under `gateway-local/src/artifacts/tests.rs`, and native STT test setup only if additional service-account assertions are required. +- Scope: replace environment-derived Windows account names with the current process SID from `whoami /user /fo csv /nh`. Parse exactly one account and canonical SID record, reject malformed or missing output, and grant `icacls` access to `*:(OI)(CI)F` before verifying that no broad principal remains. Preserve hidden-process flags, typed fail-closed errors, Unix mode enforcement, and ordinary interactive-user behavior. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-local` + - `C:\Users\Vinnie\cursor\promptforge`: `$fixture=(Resolve-Path 'local\stt-fixtures').Path; $env:PATH="$fixture;$env:PATH"; $env:PROMPTFORGE_WHISPER_LIBRARY=(Resolve-Path 'local\stt-fixtures\whisper.dll').Path; $env:PROMPTFORGE_WHISPER_MODEL=(Resolve-Path 'local\stt-fixtures\ggml-tiny.en.bin').Path; $env:PROMPTFORGE_WHISPER_AUDIO=(Resolve-Path 'local\stt-fixtures\jfk.wav').Path; cargo test --locked -p gateway-stt --lib -- --ignored --test-threads=1` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo fmt --all --check` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo clippy -p gateway-local -p gateway-stt --all-targets --all-features -- -D warnings` +- Consumes and gates: this repairs native tests under the self-hosted Windows `NetworkService` account, where `WORKGROUP\$` cannot be mapped by `icacls`. Parser tests cover ordinary users, well-known service SIDs, malformed CSV, missing SID, command failure, and SID-prefix rendering. The real Windows DACL test and native STT targets must pass without changing runner identity or bypassing cache privacy. + +### Step 20: Quiesce generations with explicit ownership [completed] + +- Artifacts: extend `gateway-stt/src/{generation.rs,service.rs}`, create `replacement.rs`, create `tests/it/generation.rs`, register it in `tests/it/main.rs`, and update ceilings and Miri filters. +- Scope: serialize replacement, close admission, count requests and worker jobs, install fresh rollback epochs, drain without reference counts, reopen on deadline, and race replacement against shutdown. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it generation` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo +nightly-2026-09-05 miri test -p gateway-stt --features test-fixtures miri_` + - `C:\Users\Vinnie\cursor\promptforge`: `node tools/check-stt-architecture.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it architecture` +- Consumes and gates: consumes bounded jobs, committed items, and complete snapshots; bounded drain gates destructive staging. + +### Step 21: Make profile replacement transactional [completed] + +- Artifacts: complete `gateway-stt/src/{replacement.rs,artifacts.rs}`; update STT-only integration in `gateway/src/{runner.rs,config_apply.rs,config_pending.rs,config_write.rs,shutdown.rs}` and `gateway/tests/it/profiles.rs`. +- Scope: sync temporary persistence before replacement, stop old workers without detachment, stage under one deadline, publish after persistence, reconstruct on determinate failure, and invalidate tokens plus request controlled shutdown on fatal outcomes. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it generation` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway --test it profiles` + - `C:\Users\Vinnie\cursor\promptforge`: `node tools/check-stt-architecture.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it architecture` +- Consumes and gates: consumes Step 20; cancellation-at-every-await and rollback outcomes gate route mounting. + +### Step 22: Separate origin predicates [completed] + +- Artifacts: add named Gateway loopback-Origin and Workshop same-origin-authority predicates with predicate-only tests in `shared-loopback/src/lib.rs`; update `crates/shared-loopback/AGENTS.md`; do not mount sockets or change Workshop yet. +- Scope: cover absent native Origin, HTTP loopback forms, malformed, foreign, wrong-port, and mismatched authorities while keeping the two policies distinct. Remove rule text that describes the crate as Gateway-only or limited to two middlewares, then retain one concise rule that the Gateway and Workshop predicates are separately named, fail closed, and never share policy semantics. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p shared-loopback` +- Consumes and gates: consumes no route state; pure predicate behavior gates Gateway sockets and later Workshop manifest adoption. + +### Step 23: Integrate generic speech facts [completed] + +- Artifacts: update `gateway/src/{model_info.rs,system.rs,lib.rs}`, `gateway/tests/it/surface.rs`, and gateway-stt status and model modules. +- Scope: expose configured, ready, GPU, and generation status; advertise physical batch names and logical `realtime-transcribe` only when ready; omit speech without the feature. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway --test it surface` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo check -p gateway --no-default-features` + - `C:\Users\Vinnie\cursor\promptforge`: `node tools/check-stt-architecture.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it architecture` +- Consumes and gates: consumes Step 18 facade and Step 21 lifecycle; status correctness gates route publication. + +### Step 24: Mount the additive Gateway route [completed] + +- Artifacts: create `gateway-stt/src/realtime/route.rs`, update `realtime/mod.rs` and `service.rs`, mount it in `gateway/src/lib.rs`, create `gateway/tests/it/realtime_stt.rs`, and register it in `gateway/tests/it/main.rs`. +- Scope: add `WS /v1/realtime?intent=transcription` while retaining batch and legacy routes; test bearer, cookie, trusted-loopback, absent and hostile socket Origins, query conflicts, send deadlines, privacy, overload, and close 1012 through scripted decoders. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway --test it realtime_stt` + - `C:\Users\Vinnie\cursor\promptforge`: `node tools/check-stt-architecture.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it architecture` +- Consumes and gates: consumes Steps 12 through 23; the independent Gateway fixture path gates Workshop relay work. + +### Step 25: Make session retirement verification event-driven [completed] + +- Artifacts: update `gateway-stt/src/realtime/registry.rs`, its test-only facade as needed, `gateway-stt/tests/it/realtime_session.rs`, exact ceilings, and architecture policy. +- Scope: replace the fixed scheduler-yield budget used to observe retired session cleanup with an explicit notification emitted when registry-owned canceled tasks finish joining and admission is released. Await that signal under a real wall-clock deadline used only as a hang guard. Preserve production ownership, exact capacity, cancellation safety, immediate reuse after completed cleanup, and Miri-compatible pure state. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it dropping_session_retains_admission_until_interim_cleanup_joins` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it realtime_session` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo +nightly-2026-09-05 miri test -p gateway-stt --features test-fixtures miri_` + - `C:\Users\Vinnie\cursor\promptforge`: `node tools/check-stt-architecture.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it architecture` +- Consumes and gates: this repairs the Linux CI failure where correct retirement did not finish within 1,000 scheduler yields. Tests must prove the waiter starts before release, cleanup wakes it exactly once, admission stays occupied until wakeup, and omitted cleanup reaches the bounded timeout. + +### Step 26: Restore cross-platform Gateway warning cleanliness [completed] + +- Artifacts: update only `gateway/build.rs`, `gateway/src/main.rs`, and focused source or compile tests when needed. +- Scope: compile the Windows application manifest constant only on Windows and apply the one-call unsafe-code lint expectation only when the Windows DPI-awareness block exists. Preserve Windows resources, process startup, lint policy, and every non-Windows code path; do not suppress warnings globally. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo check -p gateway` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo clippy -p gateway --all-targets --all-features -- -D warnings` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway` +- Consumes and gates: this repairs Linux warnings for unused `MANIFEST` and an unfulfilled `unsafe_code` expectation. Source checks must pin both declarations to Windows while existing Windows icon, manifest, and DPI tests remain green. + +### Step 27: Add the Workshop relay beside legacy [completed] + +- Artifacts: add `workshop-server/src/routes/realtime.rs`, a separate Realtime connector in `src/gateway.rs`, route composition in `src/routes.rs` and `src/app.rs`, `shared-loopback.workspace = true` in `workshop-server/Cargo.toml`, `tests/it/realtime_relay.rs`, and its registration in `tests/it/main.rs`. +- Scope: retain `routes/stt.rs`, old connector, status parsing, old UI, and every old test; the new relay fixes the upstream target, attaches the bearer, stays payload-opaque, and preserves type, close, ping, pong, origin, and subprotocol semantics. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p workshop-server --test it realtime_relay` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p workshop-server --test it stt` +- Consumes and gates: consumes Step 22 Workshop predicate and Step 24 public fixtures, but adds no dependency on Gateway or gateway-stt. + +### Step 28: Prove the actual worklet bytes [completed] + +- Artifacts: revise `workshop-server/ui/pcm-worklet.js`, create `ui/src/services/speech-capture.ts`, create `ui/test/pcm-worklet.mjs`, and consume `gateway-stt/tests/fixtures/audio/pcm16le-24khz.json`. +- Scope: make the dedicated JavaScript harness load the real worklet in a processor shim and assert little-endian bytes, clipping, transferred `ArrayBuffer` type, partial-buffer carry, and 24 kHz output; `stt-stream.mjs` is not evidence for worklet encoding. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `node --test test/pcm-worklet.mjs` + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `npm run typecheck` +- Consumes and gates: consumes Step 11 language-neutral bytes and Step 27 additive relay; byte parity gates browser migration. + +### Step 29: Migrate Workshop browser speech [completed] + +- Artifacts: create `workshop-server/ui/src/services/realtime-transcription.ts`; update `src/ui/stt.ts`, `src/ui/prompt-input.ts`, and `src/main.ts`; replace assertions in `test/agent-stt.mjs`, `agent-stt-boot.mjs`, and `stt-stream.mjs`; retain server legacy seams and `test/stt-capability.mjs`. +- Scope: switch the browser to Realtime, hypothesis replacement, authoritative completion, local status, second take, clear, overlapping items, and recoverable errors while the server fallback remains removable only after physical acceptance. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `npm run build` + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `node --test test/agent-stt.mjs test/agent-stt-boot.mjs test/stt-stream.mjs test/realtime-wire-fixtures.mjs test/pcm-worklet.mjs` +- Consumes and gates: consumes Steps 3, 27, and 28; browser acceptance gates independent full-path automation. + +### Step 30: Prove both fixture-driven halves [completed] + +- Artifacts: extend `gateway/tests/it/realtime_stt.rs`, `workshop-server/tests/it/realtime_relay.rs`, and Workshop UI sequence fixtures; add no dual-server Gateway test and no cross-product development dependency. +- Scope: Gateway independently drives canonical sequences through scripted decoders; Workshop independently drives the same sequences through a fake upstream and fake media; only installed-package acceptance claims the real dual-server path. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway --test it realtime_stt` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p workshop-server --test it realtime_relay` + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `npm test` +- Consumes and gates: consumes Steps 24 through 29; both independent halves must pass before packaging. + +### Step 31: Prevent silent model turns without selection [completed] + +- Artifacts: update `workshop-server/ui/src/ui/agent-session-view.ts`, focused UI tests, `promptforge-agent` model-call error observation, the built-in `workshop-server/agents/chat.lua` only if needed to preserve recoverable looping, and Workshop agent integration tests. +- Scope: when `ModelService.current` is empty, keep input text intact, prevent `AgentSessionService.respond`, disable or reject every click and keyboard submission path, and show a local `Select a model before sending.` status. Subscribe to model-selection changes so submission becomes available immediately after a valid selection. If a selected binding disappears between submission and `models.chat`, emit `ModelTurnFailed` through the existing observer before returning the error to Lua; the built-in `pcall` may then continue to the next `user_input` without swallowing operator-visible failure. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p promptforge-agent` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p workshop-server --test it chat_gate` + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `node --test test/agent-stt.mjs test/agent-stt-boot.mjs test/prompt-input.mjs` + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `npm run typecheck` + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `npm run build` + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `npm test` +- Consumes and gates: this repairs the installed-package observation where the model picker still showed `Select model`, the session persisted a user-input tool result with no assistant event, Gateway received no model request, and Lua returned silently to input. Tests must cover click and keyboard submission, selection arrival, selection loss after submission, one visible error, retained text, and a successful next turn. + +### Step 32: Converge Workshop state after simultaneous startup [completed] + +- Artifacts: update `workshop-server/src/heartbeat.rs`, `workshop-server/src/gateway_progress.rs`, shared progress import support only if needed, `workshop-server/ui/src/services/realtime-transcription.ts`, and focused Rust and UI lifecycle tests. +- Scope: when Gateway health stays reachable but its first profile or model refresh was empty, retry catalog and profile refresh under the existing bounded heartbeat cadence until a selectable model is retained, then restore selection exactly once. Reconnect a failed initial Realtime socket under bounded cancel-safe backoff without requiring repeated microphone clicks. Track imported Gateway progress by upstream operation ID and detach an operation when its root finishes while keeping the never-ending SSE subscription alive, so the status renderer clears progress and restores LEDs. Cancel refresh, reconnect, and progress ownership on shutdown or disposal. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p workshop-server heartbeat` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p workshop-server gateway_progress` + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `node --test test/speech-capture.mjs test/agent-stt-boot.mjs` + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `npm run typecheck` + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `npm run build` + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `npm test` +- Consumes and gates: this repairs the installed observation where Gateway became ready in under two seconds but Workshop retained an empty model picker, Realtime remained connecting for 20 to 30 seconds, and a completed profile operation left the progress bar visible instead of restoring LEDs. Tests must keep health continuously true while catalog readiness changes, keep the progress SSE open after root completion, and force Realtime reconnect cancellation. + +### Step 33: Bind live hypotheses before commit acknowledgment [completed] + +- Artifacts: update `workshop-server/ui/src/ui/realtime-stt.ts`, its service only if typed provisional-item state is needed, and focused browser speech tests. +- Scope: when a valid hypothesis arrives for an unknown item while exactly one active uncommitted take exists, bind that provisional item ID to the take before applying the snapshot. Require the later `input_audio_buffer.committed` acknowledgment to name the same item, preserve FIFO tombstones and overlapping committed items, and ignore unknown hypotheses when no active take exists. Render every revision as replacement text while recording continues, then preserve authoritative completion behavior. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `node --test test/agent-stt.mjs test/stt-stream.mjs` + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `npm run typecheck` + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `npm run build` + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `npm test` +- Consumes and gates: this repairs the installed observation where correct final text appeared only after stop because every precommit hypothesis was ignored until commit assigned the take's item ID. Tests must force multiple revisions before acknowledgment, mismatched acknowledgment, no-active-take input, overlap, clear, cancellation, and final replacement. + +### Step 34: Converge running chat sessions with the live model catalog [completed] + +- Artifacts: update `crates/workshop-server/src/session_agents.rs`, catalog and menu predicates only where needed, and `crates/workshop-server/tests/it/chat_gate.rs`. +- Scope: prevent an auto-launched built-in chat session from freezing an empty or obsolete model catalog while the Gateway profile is still loading. Keep model bindings frozen within one agent run, but make catalog generation part of the Workshop supervisor lifecycle: wait for at least one chat-capable model before starting a run, and safely relaunch over the retained event log when the chat catalog generation changes. Use one chat-capable predicate for menu readiness, picker restoration, and agent catalog construction so transcription-only entries never advertise chat readiness. Preserve cancellation, retained history, profile switching, and one visible recoverable failure if a selected binding disappears during dispatch. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p workshop-server --test it chat_gate` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p workshop-server session_agents` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo clippy -p workshop-server --all-targets --all-features -- -D warnings` +- Consumes and gates: this repairs the installed race where chat auto-launched about one second before Gateway published `claude-opus-4-6`; the picker later converged but the running session retained an empty model catalog and failed locally before any Gateway request. Tests must launch chat against an empty catalog, publish and select a chat model later, prove one completion request, replace the catalog during a profile switch, and reject transcription-only readiness. + +### Step 35: Compose each live hypothesis from disjoint transcript ownership [completed] + +- Artifacts: update `crates/gateway-stt/src/session.rs`, `crates/gateway-stt/src/realtime/server.rs`, the engine interim snapshot type and assembly only where ownership requires it, canonical wire fixtures, Gateway Realtime route tests, and the focused Workshop browser replay. +- Scope: return one coherent interim snapshot whose finalized, agreed, and tentative fields are disjoint and own their exact boundary whitespace. Serialize visible `transcript` from that snapshot exactly once. Do not independently prepend `Take::finalized` to cumulative committed text, and do not read finalized state twice while assembling one event. Preserve provisional promotion, divergent final reconciliation, authoritative completion, fallback, and item ordering. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway --test it realtime_stt` + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `node --test test/agent-stt.mjs test/stt-stream.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo clippy -p gateway-stt -p gateway --all-targets --all-features -- -D warnings` +- Consumes and gates: this repairs installed live revisions that repeated prior speech and lost spaces until Stop replaced them with the authoritative final. Tests must finalize speech with closing silence, append later speech, assert no duplicated prefix, cover nonempty finalized, agreed, and tentative fields with exact spaces, reconcile a divergent provisional prefix, serialize producer-generated canonical snapshots, and replay them through the browser replacement path. + +### Step 36: Schedule and rebase native whole-window hypotheses [completed] + +- Artifacts: update `crates/gateway-stt/src/realtime/route.rs`, the session-owned Realtime task and lifecycle modules, `crates/gateway-stt/src/take/interim.rs`, interim request or snapshot metadata, scripted decoder controls, Gateway Realtime route tests, and one ignored packaged-native Realtime test. +- Scope: remove synchronous interim decoding from each 100 ms append. Use the active `EnginePolicy` interval and minimum window, skip silent windows, permit one interim decode in flight per session, and coalesce appends to the newest eligible audio snapshot. Carry the decoded window's start and end sample offsets with its whole-window transcript. Treat native interim output as replacement text for that audio window, not an incremental suffix: revisions at one origin replace prior provisional text, a consumed segment boundary starts a new provisional region even while finalization is pending, and an advancing window origin rebases through explicit overlap without retaining a divergent prefix twice. Keep finalized text authoritative, preserve ordered completion and cancellation, and report `audio_start_ms` and `audio_end_ms` from the accepted snapshot rather than hard-coding zero. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway --test it realtime_stt` + - `C:\Users\Vinnie\cursor\promptforge`: `$fixture=(Resolve-Path 'local\stt-fixtures').Path; $env:PATH="$fixture;$env:PATH"; $env:PROMPTFORGE_WHISPER_LIBRARY=(Resolve-Path 'local\stt-fixtures\whisper.dll').Path; cargo test -p gateway --test it realtime_stt_native_incremental -- --ignored` + - `C:\Users\Vinnie\cursor\promptforge`: `node tools/check-stt-architecture.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it architecture` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo clippy -p gateway-stt -p gateway --all-targets --all-features -- -D warnings` +- Consumes and gates: this repairs the post-Step 35 installed failure where producer fields were string-disjoint but still represented overlapping audio. Tests must prove no decode before 500 ms, silence suppression, one in-flight decode with newest-snapshot coalescing, replacement of `"Why is it"` by revised `"Why is this"`, a delayed-finalization segment boundary, a tiny sliding window with advancing audio offsets and no repeated overlap, cancellation cleanup, and incrementally growing then sliding packaged-native JFK audio. + +### Step 37: Reconcile explicitly skipped final ranges [completed] + +- Artifacts: update `crates/gateway-stt/src/segment.rs`, finalization command and outcome types, `TakeState` finalized coverage, `WholeWindowState` accepted snapshot coverage, Realtime completion assembly, scripted fixtures, and focused Gateway STT and Realtime tests. +- Scope: distinguish an intentionally declined final range from a decoded empty transcript. Track the latest accepted hypothesis text with its exact committed audio coverage through sealing. Keep every nonempty or genuinely decoded final result authoritative for the range it processed, but conservatively fill only ranges the final path explicitly skipped because they were below the speech-segment threshold, below the final minimum window, or silent. Do not advance decoded-final coverage for a skipped range until it is reconciled. Never carry text beyond committed audio, reuse stale snapshot text, preserve a provisional branch over a divergent nonempty final, or turn pure silence without an accepted hypothesis into text. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway --test it realtime_stt` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt take` + - `C:\Users\Vinnie\cursor\promptforge`: `node tools/check-stt-architecture.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it architecture` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo clippy -p gateway-stt -p gateway --all-targets --all-features -- -D warnings` +- Consumes and gates: this repairs the post-Step 36 installed failure where a correct final hypothesis word vanished on Stop. Tests must combine an accepted hypothesis with stop-flush silence that closes 300 ms speech into a skipped sub-500 ms final segment, repeat for a sub-250 ms click-consumed region, prove divergent nonempty final text overrides provisional text, keep pure silence empty, and reject stale or beyond-commit hypothesis coverage. + +### Step 38: Pass installed Windows microphone acceptance [completed] + +- Artifacts: stage `crates/workshop/binaries/promptforge-gateway-x86_64-pc-windows-msvc.exe`, build `target/release/bundle/nsis/*-setup.exe`, install `promptforge-workshop.exe` and its sibling `promptforge-gateway.exe`, and create `design/generic-realtime-stt-acceptance.md`. +- Scope: follow `.github/workflows/release-workshop.yml` sidecar staging and Windows installer layout, but build a local unsigned NSIS package by passing `{"bundle":{"createUpdaterArtifacts":false}}` only through the Tauri command-line configuration override. Do not modify `tauri.conf.json`, release workflows, updater settings, or signing behavior. Install the resulting package, verify its sibling binaries and hashes, and record microphone revision, completion, second take, clear, cancellation, and recoverable permission or device failure with timestamps. State explicitly that signing was not tested. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `$env:RUSTUP_TOOLCHAIN='stable'; cargo build --release --locked -p gateway` + - `C:\Users\Vinnie\cursor\promptforge`: `$triple='x86_64-pc-windows-msvc'; New-Item -ItemType Directory -Path 'crates\workshop\binaries' -Force | Out-Null; Copy-Item 'target\release\promptforge-gateway.exe' "crates\workshop\binaries\promptforge-gateway-$triple.exe" -Force` + - `C:\Users\Vinnie\cursor\promptforge`: `$env:RUSTUP_TOOLCHAIN='stable'; cargo install tauri-cli --locked` + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop`: `$env:RUSTUP_TOOLCHAIN='stable'; cargo tauri build --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":false}}'` + - `C:\Users\Vinnie\cursor\promptforge`: `$setup=Get-ChildItem -Recurse 'target\release\bundle\nsis' -Filter '*-setup.exe' | Select-Object -First 1; if (-not $setup) { throw 'no NSIS installer' }; Start-Process $setup.FullName -ArgumentList '/S' -Wait` + - `C:\Users\Vinnie\cursor\promptforge`: `$workshop=@($env:LOCALAPPDATA,$env:ProgramFiles,${env:ProgramFiles(x86)}) | ForEach-Object { Get-ChildItem $_ -Recurse -Filter 'promptforge-workshop.exe' -ErrorAction SilentlyContinue } | Select-Object -First 1; $gateway=@($env:LOCALAPPDATA,$env:ProgramFiles,${env:ProgramFiles(x86)}) | ForEach-Object { Get-ChildItem $_ -Recurse -Filter 'promptforge-gateway.exe' -ErrorAction SilentlyContinue } | Select-Object -First 1; if (-not $workshop -or -not $gateway) { throw 'installed Workshop or Gateway missing' }; Start-Process $workshop.FullName` +- Consumes and gates: consumes Steps 30 through 37; the operator must exercise the installed application and record passing evidence, which alone gates legacy removal. + +### Step 39: Remove legacy seams and tests [completed] + +- Artifacts: remove gateway-stt legacy route/status code from `src/stt.rs` after moving retained `Take` behavior, remove old exports and Gateway mounts, remove Workshop `routes/stt.rs`, old connector/status parsing and capability proxy, update route composition, remove `workshop-server` from `gateway-stt/Cargo.toml`, update `Cargo.lock`, update `crates/gateway/AGENTS.md` and `crates/workshop-server/AGENTS.md`, and retire `tests/it/legacy_stream.rs`, old `tests/it/stt.rs` registrations, and UI `test/stt-capability.mjs`. +- Scope: map every retired legacy assertion to Steps 3, 24, 27, 29, and 30 replacement evidence in the commit rationale; deletion is justified only because those fixtures preserve the behavior, and the final allowlist, zero legacy symbols, and only batch plus Realtime routes are enforced. Delete Gateway's temporary `gateway-stt -> workshop-server` exception and Workshop's `spawn_with_routes`, Gateway-owned socket attachment, status-bus, and Whisper-job language. Add only one Workshop-local rule if needed: its Realtime relay authenticates upstream, validates browser origin, and never parses speech payloads or owns speech state. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p workshop-server --test it` + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `npm test` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo check -p gateway --no-default-features` + - `C:\Users\Vinnie\cursor\promptforge`: `node tools/check-stt-architecture.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it architecture` +- Consumes and gates: consumes the passing installed-package record; failed replacement coverage blocks deletion. + +### Step 40: Finalize architecture and documentation [completed] + +- Artifacts: finalize the architecture harness and all four ceilings; audit root `AGENTS.md` plus the targeted `gateway-stt`, `gateway-stt-engine`, `gateway-stt-backend-whisper`, `gateway`, `shared-loopback`, and `workshop-server` rule files for stale pre-migration text; update `.github/workflows/ci.yml`, `.github/workflows/stt-miri.yml`, `crates/gateway/README.md`, `crates/gateway-config/README.md`, `crates/workshop-server/README.md`, source guides `guide/src/gateway/05-speech.md`, `guide/src/gateway/10-serving-and-observing.md`, `guide/src/workshop/01-application.md`, and `guide/src/workshop/07-voice.md`; generated `guide/src/SUMMARY.md`, `guide/src/gateway/index.md`, `guide/src/workshop/index.md`, `guide/src/language/index.md`, `guide/src/agent/index.md`, `guide/promptforge-gateway-guide.md`, `guide/promptforge-workshop-guide.md`, `guide/promptforge-language-guide.md`, and `guide/promptforge-agent-guide.md`; `design/generic-realtime-stt.md`; and `design/generic-realtime-stt-acceptance.md`. +- Scope: remove temporary allowlist edges, enforce exact final dependencies, cycles, public counts, every 500-line ceiling, Gateway-only build isolation, normal-CI scripted coverage, and before or after debt counts. The rules audit deletes obsolete or duplicated lines first and edits only files with a concrete contradiction. Keep root `AGENTS.md`, `gateway-whisper-ffi/AGENTS.md`, `gateway-config/AGENTS.md`, and `workshop-server/ui/AGENTS.md` unchanged unless the final implementation proves one of their current constraints false. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `node tools/check-stt-architecture.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it architecture` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo run -p build-user-guide` + - `C:\Users\Vinnie\cursor\promptforge`: `$env:RUSTUP_TOOLCHAIN='stable'; cargo install mdbook --version 0.4.44 --locked` + - `C:\Users\Vinnie\cursor\promptforge`: `mdbook build guide` +- Consumes and gates: consumes Step 39 final topology; final verification starts only with zero temporary exceptions. + +### Step 41: Bookend Gateway serving logs [completed] + +- Artifacts: update only `crates/gateway/src/main.rs`, `crates/gateway/tests/it/boot.rs`, and this step's active-plan bookkeeping. +- Scope: in `init_logging()`, immediately after installing the subscriber with the file layer, emit the first serving-run file record as `promptforge-gateway {version} starting` before the existing `logging to {path}` record. After the serving result determines success or failure and before `LogRuntime::shutdown`, emit `gateway exiting` on success or `gateway exiting after a fatal error` after `log_error_chain` on failure. Emit terminal records only when file logging initialized. Preserve no-subscriber behavior for help, version, diagnostics, second-instance handoff, and stdout-only fallback. Do not modify `gateway-logging`, CLI parsing, queues, sinks, retention, rotation, redaction, or subscriber ownership. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo fmt --all --check` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo clippy -p gateway --all-targets --all-features -- -D warnings` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway` +- Consumes and gates: this operator-requested observability correction is independent of STT and follows Step 40 only to preserve a single ordered run. Extend the existing child-process log tests so a serving log's first line contains the versioned launch record, normal route shutdown leaves the clean terminal record last, and fatal-chain logging places the fatal terminal record after the complete chain. Existing no-log and no-rotation tests must remain unchanged and green. Step 42's full release verification must pass after this change. + +### Step 42: Run every release gate and repeat acceptance [completed] + +- Artifacts: append command results, hashes, ratchet counts, native equivalence, generated-doc cleanliness, and repeated installed-microphone evidence to `design/generic-realtime-stt-acceptance.md`; if the installed pair exposes a release-blocking defect, repair it in this still-provisional final commit and repeat every affected gate. +- Scope: run every exit criterion independently under PowerShell 5.1 and stop on any failure. The final installed run exposed one such defect: after a local sidecar Gateway exits, Workshop keeps a dead random-port endpoint forever. Add local-sidecar supervision in `crates/workshop`, a replaceable endpoint and credentials snapshot shared by every `workshop-server` Gateway client path, and bounded connection-file re-resolution plus sibling relaunch. Identify a replacement by a new PID or boot identity, validate its live process image, health, and bearer acceptance, then atomically publish the exact endpoint and credential pair from its connection file before heartbeat, progress, catalog, chat, proxy, and Realtime retries resume. `[server].api_key` is a long-term configured credential: generate it only when creating a missing default config, preserve it when the config is unchanged, and propagate a configured edit atomically after restart. The OS may reuse a port, so neither the port nor credential must differ across a valid replacement. A browser Realtime retry after replacement must reach ready without reloading Workshop. Never relaunch or mutate an explicitly configured LAN Gateway, never expose bearer keys, and preserve the Step 41 logging scope without adding shutdown-source records. +- Focused test commands: + - `C:\Users\Vinnie\cursor\promptforge`: `cargo fmt --all --check` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo clippy --workspace --all-targets --all-features -- -D warnings` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test --workspace` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test --workspace --all-features --doc` + - `C:\Users\Vinnie\cursor\promptforge`: `$env:RUSTDOCFLAGS='-D warnings'; cargo doc --workspace --no-deps --all-features` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo deny check` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo build -p gateway` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo build -p workshop` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo check -p gateway --no-default-features` + - `C:\Users\Vinnie\cursor\promptforge`: `$fixture=(Resolve-Path 'local\stt-fixtures').Path; $env:PATH="$fixture;$env:PATH"; $env:PROMPTFORGE_WHISPER_LIBRARY=(Resolve-Path 'local\stt-fixtures\whisper.dll').Path; cargo test -p gateway-stt-backend-whisper --test native_whisper -- --ignored` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo +nightly-2026-09-05 miri test -p gateway-stt-engine --features test-fixtures miri_` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo +nightly-2026-09-05 miri test -p gateway-stt --features test-fixtures miri_` + - `C:\Users\Vinnie\cursor\promptforge`: `node tools/check-stt-architecture.mjs` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it architecture` + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `npm run typecheck` + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `npm run build` + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop-server\ui`: `npm test` + - `C:\Users\Vinnie\cursor\promptforge\crates\gateway-config-ui\ui`: `npm run typecheck` + - `C:\Users\Vinnie\cursor\promptforge\crates\gateway-config-ui\ui`: `npm run build` + - `C:\Users\Vinnie\cursor\promptforge\crates\gateway-config-ui\ui`: `npm test` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo run -p build-user-guide` + - `C:\Users\Vinnie\cursor\promptforge`: `mdbook build guide` + - `C:\Users\Vinnie\cursor\promptforge`: `git diff --exit-code -- guide/src/SUMMARY.md guide/src/gateway/index.md guide/src/workshop/index.md guide/src/language/index.md guide/src/agent/index.md guide/promptforge-gateway-guide.md guide/promptforge-workshop-guide.md guide/promptforge-language-guide.md guide/promptforge-agent-guide.md` + - `C:\Users\Vinnie\cursor\promptforge`: `$env:RUSTUP_TOOLCHAIN='stable'; cargo build --release --locked -p gateway` + - `C:\Users\Vinnie\cursor\promptforge`: `$triple='x86_64-pc-windows-msvc'; New-Item -ItemType Directory -Path 'crates\workshop\binaries' -Force | Out-Null; Copy-Item 'target\release\promptforge-gateway.exe' "crates\workshop\binaries\promptforge-gateway-$triple.exe" -Force` + - `C:\Users\Vinnie\cursor\promptforge`: `$env:RUSTUP_TOOLCHAIN='stable'; cargo install tauri-cli --locked` + - `C:\Users\Vinnie\cursor\promptforge\crates\workshop`: `$env:RUSTUP_TOOLCHAIN='stable'; cargo tauri build --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":false}}'` + - `C:\Users\Vinnie\cursor\promptforge`: `$setup=Get-ChildItem -Recurse 'target\release\bundle\nsis' -Filter '*-setup.exe' | Select-Object -First 1; if (-not $setup) { throw 'no NSIS installer' }; Start-Process $setup.FullName -ArgumentList '/S' -Wait` + - `C:\Users\Vinnie\cursor\promptforge`: `$workshop=@($env:LOCALAPPDATA,$env:ProgramFiles,${env:ProgramFiles(x86)}) | ForEach-Object { Get-ChildItem $_ -Recurse -Filter 'promptforge-workshop.exe' -ErrorAction SilentlyContinue } | Select-Object -First 1; $gateway=@($env:LOCALAPPDATA,$env:ProgramFiles,${env:ProgramFiles(x86)}) | ForEach-Object { Get-ChildItem $_ -Recurse -Filter 'promptforge-gateway.exe' -ErrorAction SilentlyContinue } | Select-Object -First 1; if (-not $workshop -or -not $gateway) { throw 'installed Workshop or Gateway missing' }; Start-Process $workshop.FullName` +- Consumes and gates: consumes Step 41, then repeats the Step 38 installed-package microphone scenarios. In addition to every listed command, tests must keep the installed local pair alive beyond 60 seconds, terminate the local Gateway while Workshop remains open, prove one bounded relaunch publishes a validated new process or boot identity with the exact connection-file endpoint and credential, and prove health, model catalog, chat, progress, config proxy, and Realtime recover against that replacement. Deterministic coverage must include a same-port and same-key replacement identity plus a configured key change propagated after restart. Explicit LAN configuration must stay fixed and unrelaunched. Completion requires every command, ratchet, generated-doc check, and physical scenario to pass with no open finding. + +Stop and revise after repeated same-signature failures, an upstream wire incompatibility, an unacceptable dependency license, Rust 1.89 incompatibility, unsafe or transitive expansion, or evidence that two generations cannot satisfy memory constraints. Do not modify Gateway CLI, diagnostics, logging queues, sinks, rotation, logging lifecycle beyond Step 41's exact two records, or completed Workshop baseline-ratchet work. VAD tuning, decode-quality changes, native streaming, batching, denoising, a new WER harness, dynamic plugins, a fifth STT crate, shared STT wire types, browser credentials, WebRTC, and automatic turn detection remain outside this plan. \ No newline at end of file diff --git a/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md new file mode 100644 index 00000000..a55bc547 --- /dev/null +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -0,0 +1,542 @@ +--- +name: collect-promptforge-debt +overview: Remove technical debt attributable to the 53 commits between upstream master and local master. The plan covers bounded logging, immediate legacy configuration removal, STT test infrastructure, Gateway and Workshop lifecycle simplification, strict Realtime decoding, and validated sidecar recovery. +todos: + - id: logging-bounds + content: Bound logging memory, disk, ordering, loss reporting, redaction, and stalls + status: pending + - id: config-and-ci + content: Retire the legacy STT configuration shim and stabilize native test infrastructure + status: pending + - id: lifecycle-structure + content: Extract Gateway and Workshop lifecycle state machines and ratchet their tests + status: pending + - id: sidecar-boundary + content: Validate sidecar capabilities and unify replacement and shutdown ownership + status: pending + - id: verify-removal + content: Run focused, architecture, native, UI, and release exit gates + status: pending +isProject: false +--- + +# PromptForge Attributable Debt Removal + +## Product Requirements + +- Repository: [promptforge](C:/Users/Vinnie/cursor/promptforge). +- Baseline: live `upstream/master` at `d539a6d90c5f1054e0917ccd74251ab3a6df7461`. +- Endpoint: local `master` at `5c80bbd8685f12378eed012290727e6195abb842`. +- Target: the exact 53-commit range `d539a6d90..5c80bbd8`. +- Worktree inclusion: none. The worktree was clean. +- Evidence: complete target commit messages and diffs, current code at the endpoint, [vibe/archdoc.md](C:/Users/Vinnie/cursor/promptforge/vibe/archdoc.md), [vibe/archdoc-next.md](C:/Users/Vinnie/cursor/promptforge/vibe/archdoc-next.md), [the Realtime STT plan](C:/Users/Vinnie/cursor/promptforge/vibe/2026-09-05-2-generic-realtime-stt.md), [the final STT design](C:/Users/Vinnie/cursor/promptforge/design/generic-realtime-stt.md), and [acceptance evidence](C:/Users/Vinnie/cursor/promptforge/design/generic-realtime-stt-acceptance.md). +- Analysis limits: static read-only analysis only. No tests, fault injection, native fixtures, external runner configuration, or deployment census ran. Practical collision rates and deployed legacy-config counts remain unknown. +- Cleanup goals: + - Bound logging memory, disk, producer latency, and shutdown time with explicit loss reporting. + - Move redaction before text formatting and preserve post-format scanning as defense in depth. + - Delete the legacy `[workshop.stt]` compatibility paths and duplicated fixture logic. + - Replace temporal lifecycle meshes with explicit transaction or reducer state. + - Make sidecar validation a type-level precondition and use one authoritative Gateway identity snapshot. + - Split oversized integration suites and ratchet both production and test surfaces. +- Non-goals: + - Unrelated pre-existing debt. + - A fifth production STT crate, a new speech protocol, or changed installed STT behavior. + - Per-process Gateway bearer rotation. `[server].api_key` remains a configured long-term credential. + - Reassigning Gateway, Workshop, or relay component ownership beyond the validated sidecar capability selected below. + - Reworking log message content unrelated to bounds, ordering, loss, redaction, or retention. +- Success criteria: + - Every retained debt ID has a concrete target state and a regression or architecture gate. + - Logging has explicit byte, time, and disk budgets with observable truncation or loss. + - Configuration version 2 accepts canonical top-level `[stt]` only, and the local installed configuration remains valid without rewriting. + - Feature-enabled fixture API size is measured, temporary dead-code allowances are gone, and native CI validates an exact toolchain contract. + - Gateway profile switching, Workshop Realtime parsing, dictation ownership, agent supervision, and sidecar recovery have explicit bounded state owners. + - Existing public wire behavior, config version 2, installed behavior, and release gates remain green. + +## Debt Inventory + +- `PF-GWLOG-001` - introduced by `f303718e` in [gateway-logging/src/writer.rs](C:/Users/Vinnie/cursor/promptforge/crates/gateway-logging/src/writer.rs), [queue.rs](C:/Users/Vinnie/cursor/promptforge/crates/gateway-logging/src/queue.rs), and [redact.rs](C:/Users/Vinnie/cursor/promptforge/crates/gateway-logging/src/redact.rs). Record count is bounded but record and aggregate bytes are not. Impact: unbounded memory. Reversal cost: medium. Target: per-record and aggregate-byte limits with visible truncation or loss. +- `PF-GWLOG-002` - introduced by `f303718e` in [gateway-logging/src/queue.rs](C:/Users/Vinnie/cursor/promptforge/crates/gateway-logging/src/queue.rs). Sequence reservation happens before queue admission, so concurrent records can drain out of causal order. Impact: misleading chronology. Reversal cost: low. Target: assign sequence atomically with admission. +- `PF-GWLOG-003` - introduced by `f303718e` in [gateway-logging/src/queue.rs](C:/Users/Vinnie/cursor/promptforge/crates/gateway-logging/src/queue.rs) and [worker.rs](C:/Users/Vinnie/cursor/promptforge/crates/gateway-logging/src/worker.rs). Eviction summaries wait for a completely empty queue rather than the end of pressure. Impact: silent record loss. Reversal cost: low. Target: one summary per pressure episode after a defined low-water transition. +- `PF-GWLOG-004` - worsened by `1009b3f4` in [gateway-logging/src/config.rs](C:/Users/Vinnie/cursor/promptforge/crates/gateway-logging/src/config.rs) and [worker.rs](C:/Users/Vinnie/cursor/promptforge/crates/gateway-logging/src/worker.rs). Retention grew to five prior runs without a disk-byte bound. Impact: filesystem exhaustion. Reversal cost: medium. Target: fixed-size segments under one aggregate budget while preserving current diagnostic names. +- `PF-GWLOG-005` - introduced by `f303718e` across [queue.rs](C:/Users/Vinnie/cursor/promptforge/crates/gateway-logging/src/queue.rs), [runtime.rs](C:/Users/Vinnie/cursor/promptforge/crates/gateway-logging/src/runtime.rs), and [gateway/src/main.rs](C:/Users/Vinnie/cursor/promptforge/crates/gateway/src/main.rs). Protected-record producers, sink writes, and shutdown joins can block forever. Impact: frozen application threads or exit. Reversal cost: high. Target: finite waits followed by explicit loss, as selected by the operator. +- `PF-GWLOG-006` - worsened by `f303718e` and `1009b3f4` in [gateway-logging/src/redact.rs](C:/Users/Vinnie/cursor/promptforge/crates/gateway-logging/src/redact.rs). Text patterns do not cover all structured credentials, cookies, prompts, paths, payloads, or nested errors. Impact: persisted sensitive data. Reversal cost: medium to high. Target: typed field redaction before formatting plus adversarial post-format defense. +- `STT-CORE-001` - introduced by `3642d6dd` in [gateway-config/src/config/imp.rs](C:/Users/Vinnie/cursor/promptforge/crates/gateway-config/src/config/imp.rs) and [gateway-config-ui/ui/src/services/config-store.ts](C:/Users/Vinnie/cursor/promptforge/crates/gateway-config-ui/ui/src/services/config-store.ts). Rust and TypeScript indefinitely duplicate `[workshop.stt]` migration. Impact: compatibility drift and redundant parsing. Reversal cost: low for this pre-1.0 installation because the local file is already canonical. Target: config version 2 accepts only top-level `[stt]`; both migration shims are removed. +- `STT-CORE-002` - introduced by `c7c1c1f7` and expanded later across [gateway-stt-backend-whisper/src/prompt.rs](C:/Users/Vinnie/cursor/promptforge/crates/gateway-stt-backend-whisper/src/prompt.rs), [gateway-stt/src/test_fixtures/native.rs](C:/Users/Vinnie/cursor/promptforge/crates/gateway-stt/src/test_fixtures/native.rs), and native integration helpers. Five fixture resolvers can drift. Impact: inconsistent native gates. Reversal cost: low. Target: one feature-gated non-production resolver with explicit caller defaults. +- `STT-CORE-003` - introduced by `75f4cb30` and expanded through `25501883` in [gateway-stt-engine/src/test_fixtures.rs](C:/Users/Vinnie/cursor/promptforge/crates/gateway-stt-engine/src/test_fixtures.rs) and [gateway-stt/src/test_fixtures.rs](C:/Users/Vinnie/cursor/promptforge/crates/gateway-stt/src/test_fixtures.rs). The feature-gated fixture API grows outside public-root ratchets. Impact: quasi-public test contract constrains refactors. Reversal cost: medium. Target: feature-enabled public API accounting followed by scenario-level narrowing. +- `STT-CORE-004` - introduced by `4b490073` and `101dedac` in [gateway-stt/src/lib.rs](C:/Users/Vinnie/cursor/promptforge/crates/gateway-stt/src/lib.rs). Module-wide dead-code allowances outlived production wiring. Impact: obsolete code can accumulate silently. Reversal cost: low. Target: remove broad allowances and retain only justified item-level exceptions. +- `STT-CORE-005` - introduced by `b6021e4c` in [.github/workflows/stt-miri.yml](C:/Users/Vinnie/cursor/promptforge/.github/workflows/stt-miri.yml). Native CI depends on floating `stable` and a service-account Cargo layout. Impact: unreproducible runner failures. Reversal cost: medium. Target: exact toolchain and versioned runner provisioning contract. +- `PF-RTSTT-DC-001` - worsened by `467a2622`, `60165006`, and `1b50919d` in [gateway/src/lib.rs](C:/Users/Vinnie/cursor/promptforge/crates/gateway/src/lib.rs) and [config_write.rs](C:/Users/Vinnie/cursor/promptforge/crates/gateway/src/config_write.rs). Profile-switch phases and rollback state remain concentrated in the 5,000-line root module. Impact: temporal coupling across every runtime participant. Reversal cost: medium to high. Target: a private transaction module with explicit phase values. +- `PF-RTSTT-DC-002` - introduced by `467a2622` in [gateway/src/config_write.rs](C:/Users/Vinnie/cursor/promptforge/crates/gateway/src/config_write.rs). PID plus process-local sequence temporary names can collide with crash residue after PID reuse. Impact: valid profile switches can fail. Reversal cost: low. Target: high-entropy process nonce with bounded create-new retry. +- `PF-RTSTT-DC-003` - introduced by `7452751b` and worsened by `94357c39` in [realtime-transcription.ts](C:/Users/Vinnie/cursor/promptforge/crates/workshop-server/ui/src/services/realtime-transcription.ts). Production validates only fields it consumes while test fixtures enforce the full frozen event shape. Impact: production and canonical contract drift. Reversal cost: medium. Target: one exhaustive pure decoder used by production and fixture tests. +- `PF-RTSTT-DC-004` - introduced by `7452751b` and worsened by `aeec7b48` in [realtime-stt.ts](C:/Users/Vinnie/cursor/promptforge/crates/workshop-server/ui/src/ui/realtime-stt.ts). Five collections, lifecycle flags, capture state, and editor offsets are coordinated in one callback mesh. Impact: stale ownership and rollback defects. Reversal cost: medium. Target: a pure `TakeRegistry` reducer emitting editor and capture effects. +- `PF-RTSTT-DC-005` - introduced by `fb4e0bfe` and expanded by `13fb8eef` in [session_agents/supervisor.rs](C:/Users/Vinnie/cursor/promptforge/crates/workshop-server/src/session_agents/supervisor.rs) and [lifecycle.rs](C:/Users/Vinnie/cursor/promptforge/crates/workshop-server/src/session_agents/lifecycle.rs). Run completion, catalog replacement, Gateway replacement, cancellation, and accepted-turn settlement scale as branch interactions. Impact: exactly-once settlement risk. Reversal cost: medium. Target: explicit supervisor events and transition reducer. +- `PF-RTSTT-DC-006` - worsened across `1b50919d`, `06cba48a`, `6ce38729`, and `49441166` in [gateway/tests/it/realtime_stt.rs](C:/Users/Vinnie/cursor/promptforge/crates/gateway/tests/it/realtime_stt.rs), [realtime_relay.rs](C:/Users/Vinnie/cursor/promptforge/crates/workshop-server/tests/it/realtime_relay.rs), and [chat_gate.rs](C:/Users/Vinnie/cursor/promptforge/crates/workshop-server/tests/it/chat_gate.rs). Integration suites reached 1,778, 680, and 1,098 lines outside ratchets. Impact: coupled fixtures and hard-to-localize failures. Reversal cost: low. Target: concern-based files plus test-file ceilings. +- `DC-PF-P2-001` - introduced by `13fb8eef` in [workshop/src/gateway.rs](C:/Users/Vinnie/cursor/promptforge/crates/workshop/src/gateway.rs) and [main.rs](C:/Users/Vinnie/cursor/promptforge/crates/workshop/src/main.rs). Supervisor shutdown signals then abandons its thread and owned blocking work. Impact: post-teardown probing, launch, or publication. Reversal cost: medium. Target: cancellation-aware probes and a finite joined shutdown. +- `DC-PF-P2-002` - introduced by `13fb8eef` in [gateway_binding.rs](C:/Users/Vinnie/cursor/promptforge/crates/workshop-server/src/gateway_binding.rs) and [serve.rs](C:/Users/Vinnie/cursor/promptforge/crates/workshop-server/src/serve.rs). Public updater accepts a raw connection file while validation lives only in one caller. Impact: public trust-boundary bypass. Reversal cost: high. Target: updater accepts an unforgeable validated-connection capability. +- `DC-PF-P2-003` - worsened by `13fb8eef` across [gateway_binding.rs](C:/Users/Vinnie/cursor/promptforge/crates/workshop-server/src/gateway_binding.rs), [workshop/src/main.rs](C:/Users/Vinnie/cursor/promptforge/crates/workshop/src/main.rs), and [menu.rs](C:/Users/Vinnie/cursor/promptforge/crates/workshop/src/menu.rs). Client consumers and quit handling publish Gateway identity in two stores. Impact: quit can target a retired process and leave the replacement alive. Reversal cost: medium. Target: one validated authoritative snapshot for clients and shutdown. +- `DC-PF-P2-004` - worsened by `13fb8eef` in [workshop/src/gateway.rs](C:/Users/Vinnie/cursor/promptforge/crates/workshop/src/gateway.rs). Boot planning, launch, validation, identity, supervision, recovery, and tests occupy 854 lines. Impact: broad review and regression boundary. Reversal cost: low. Target: extract supervision and identity into private modules with ceilings. +- `DC-PF-P2-005` - worsened by `13fb8eef` across [ui/src/ui/stt.ts](C:/Users/Vinnie/cursor/promptforge/crates/workshop-server/ui/src/ui/stt.ts), [realtime-stt.ts](C:/Users/Vinnie/cursor/promptforge/crates/workshop-server/ui/src/ui/realtime-stt.ts), and [prompt-input.ts](C:/Users/Vinnie/cursor/promptforge/crates/workshop-server/ui/src/ui/prompt-input.ts). Every input adapter exposes document-end and plain-text details for composition policy. Impact: editor representation leaks into Realtime lifecycle code. Reversal cost: medium. Target: one target-owned insertion-context operation carrying anchor, original text, and required prefix. + +## Technical Design + +- Logging settlement for `PF-GWLOG-001` through `PF-GWLOG-006`: + - Add one immutable limits object covering maximum formatted record bytes, aggregate queued bytes, producer wait, shutdown wait, segment bytes, and aggregate retained bytes. + - Format into a bounded writer. Truncate at a valid text boundary with an explicit marker, or reject the record and increment the same observable loss episode. + - Assign sequence under the queue mutex at successful admission. Track queued bytes with record counts and preserve lane priority within that one admission order. + - End a pressure episode at a defined low-water transition, not only at empty, and enqueue exactly one summary containing dropped and truncated counts. + - Apply the selected bounded-loss contract: protected producers wait only for the configured budget, then record loss through a preallocated counter path. Runtime shutdown waits only for its budget, records an emergency diagnostic when possible, and detaches without an unbounded join. + - Redact structured tracing fields by classified field name and secret type before formatting. Keep the bounded textual scanner for dependency errors and unstructured messages. + - Rotate fixed-size `gateway.log` segments under one aggregate byte budget while preserving `gateway.log` and numbered diagnostic names. Reserve enough segment space for a truncation marker and terminal record, and prune oldest segments before admitting a new one. +- Configuration settlement for `STT-CORE-001`: + - Keep `config-version = 2`. + - Delete Rust `migrate_legacy_stt` and TypeScript `canonicalizeStt`. + - Reject `[workshop.stt]` through the existing unknown-field validation instead of rewriting it. + - Keep canonical top-level `[stt]` parsing and UI serialization unchanged. + - Verify `C:\Users\Vinnie\.promptforge\gateway.toml` contains no legacy section, perform no write to it, and leave it byte-for-byte unchanged. +- Test infrastructure and CI settlement for `STT-CORE-002` through `STT-CORE-005` and `PF-RTSTT-DC-006`: + - Centralize native fixture resolution behind the existing feature-gated STT test infrastructure rather than adding a production crate. Preserve caller-specific fallback roots as explicit parameters. + - Generate and ratchet feature-enabled public API snapshots for both STT fixture surfaces. Narrow low-level synchronization controls to scenario-level operations only after current consumers are inventoried. + - Remove module-wide dead-code allowances and fix or annotate only genuinely configuration-specific items. + - Pin an exact native Rust toolchain in the workflow and validate a versioned self-hosted runner contract before cache or test work. + - Split Gateway Realtime, Workshop relay, and chat integration suites by authentication, protocol, lifecycle, recovery, overload, and canonical sequence. Add physical-line ceilings and preserve discovered test counts. +- Gateway lifecycle settlement for `PF-RTSTT-DC-001` and `PF-RTSTT-DC-002`: + - Extract a private `profile_switch` transaction that owns target profile, cancellation token, prepared persistence, old runtime snapshot, staged routing and speech replacements, and terminal outcome. + - Represent prepared, cutover, staged, and committed phases as values so invalid rollback or publication order cannot be called. + - Keep existing locks and external behavior while moving persistence and rollback helpers out of the root module. + - Name preparation files with a process-random nonce and bounded `create_new` retry. Never delete residue unless ownership is proven. +- Workshop protocol and state settlement for `PF-RTSTT-DC-003` through `PF-RTSTT-DC-005` and `DC-PF-P2-005`: + - Extract a pure exhaustive Realtime event decoder returning a discriminated union. Validate exact required fields, nullable fields, IDs, content index, revision, transcript partition, audio spans, completion usage, and unsupported event types. Drive both production and canonical fixture mutation tests through it. + - Replace the callback-owned dictation maps and flags with a pure `TakeRegistry` transition reducer. Inputs are typed service events and user actions; outputs are editor, capture, status, and wire effects. + - Move insertion policy into `SttInputTarget::insertionContext`, returning the selected range, original text, and immutable composition prefix. The registry never reads document structure directly. + - Model agent supervision with explicit events for run completion, catalog generation, Gateway generation, operator cancellation, accepted input, and terminal settlement. A pure transition function decides wait, cancel, preserve, relaunch, or close effects. +- Sidecar settlement for `DC-PF-P2-001` through `DC-PF-P2-004`: + - Add a public but unforgeable `ValidatedConnection` capability in `shared-sidecar`. Constructors remain private; validation proves process image, boot identity, health, and bearer acceptance. Expose redacted accessors needed to build a consumer snapshot. + - Change `GatewayUpdater` to accept only `ValidatedConnection`. Remove raw `ConnectionFile` publication from the public Workshop server API. + - Store the validated connection identity in the same immutable `GatewayBinding` snapshot as HTTP and model clients. Route quit through the current authoritative snapshot and remove the separate `GatewaySlot`. + - Make resolve, validation, wait, and launch loops cancellation-aware. `GatewaySupervisor` owns and joins its thread under a finite shutdown budget, and publication is impossible after cancellation. + - Split boot planning and one-shot launch from continuous supervision, identity, and recovery tests. Ratchet each resulting module. + +## Testing Plan + +- `PF-GWLOG-001`, `PF-GWLOG-003`, and `PF-GWLOG-005`: inject oversized records, variable-size concurrent pressure, a permanently stalled sink, and shutdown during saturation. Assert strict peak bytes, bounded producer and exit latency, one summary per episode, and explicit loss or truncation. +- `PF-GWLOG-002`: pause one producer before admission and prove successful admission sequence is global write order. +- `PF-GWLOG-004`: exceed segment and aggregate budgets across active and retained logs. Assert current diagnostic names, oldest-first pruning, terminal-record preservation, and total bytes at or below budget. +- `PF-GWLOG-006`: adversarial structured and textual credentials, Basic and Bearer authorization, cookies, URLs, multiline errors, prompts, paths, request bodies, and nested chains must persist no protected values. +- `STT-CORE-001`: version 2 canonical `[stt]` parsing, legacy `[workshop.stt]` rejection, mixed-form rejection, canonical UI round-trip, absence of browser canonicalization, and byte-for-byte local configuration preservation. +- `STT-CORE-002` and `STT-CORE-003`: every native test target must resolve identical explicit fixtures and retain caller fallbacks; feature-enabled public API snapshots must fail on unreviewed growth; default builds must expose no fixture symbols. +- `STT-CORE-004`: default, all-feature, test, Miri, and featureless lint configurations pass with dead-code diagnostics active. +- `STT-CORE-005`: native preflight accepts only the pinned toolchain and versioned runner layout, rejects wrong or missing versions before cache use, and runs all native Whisper jobs on the self-hosted runner. +- `PF-RTSTT-DC-001` and `PF-RTSTT-DC-002`: retain every profile-switch cancellation, rollback, indeterminate persistence, atomic publication, and featureless test; add deterministic temporary-name collisions and crash residue. +- `PF-RTSTT-DC-003`: mutate each required and forbidden Realtime field in production decoding, then replay every canonical sequence through the same decoder. +- `PF-RTSTT-DC-004` and `DC-PF-P2-005`: reducer invariants cover overlap, tombstones, precommit binding, rollback, reconnect, sequential spacing, completion authority, selection replacement, textarea, and ProseMirror. +- `PF-RTSTT-DC-005`: transition tables cover delayed catalog, profile and Gateway replacement during accepted input, operator cancel, retained history, close, and exactly-once settlement. +- `PF-RTSTT-DC-006` and `DC-PF-P2-004`: test count before and after every split is identical; new source and test ceilings pass. +- `DC-PF-P2-001`: block each sidecar resolve, validation, launch, and health phase, request Workshop exit, and prove joined termination within budget with no later publication. +- `DC-PF-P2-002` and `DC-PF-P2-003`: raw connection files cannot publish; wrong image, boot identity, health, or bearer cannot create a capability; same-port and same-key replacement works; configured-key replacement is atomic; replacement raced with quit targets one current generation. +- Exit checks: repository formatting, warnings-denied workspace lint, workspace tests, documentation tests, architecture gates, feature-enabled API snapshots, native Whisper, both Miri targets, both UI suites, guide generation cleanliness, unsigned local package recovery, and existing signed release CI. + +## Decision Record + +- Scope correction: the tracked branch is `origin/master`, but the requested upstream baseline is the separate `upstream/master` remote at `d539a6d90`. The live remote was verified without changing local refs. +- Logging stall policy: bounded producer and shutdown waits with explicit loss. Rejected indefinite protected-record retention because it can freeze arbitrary threads and process exit. Rejected an emergency spool because it creates another sink and budget lifecycle. +- Sidecar trust boundary: an unforgeable validated-connection capability. Rejected caller-only validation because the public updater remains forgeable. Rejected moving all supervision into `workshop-server` because it changes component ownership more broadly. +- Legacy configuration: keep version 2 and remove `[workshop.stt]` support immediately. The repository is pre-1.0 and the local installation is already canonical, so no migration mechanism or new schema version is justified. Rejected automatic migration, an operator command, and a deprecation window because each preserves compatibility machinery that this installation does not need. +- Log retention: fixed-size segments under an aggregate byte budget while retaining current names. Rejected per-run discard because late terminal diagnostics could be lost. Rejected prune-only run rotation because the active file remains unbounded. +- Reversible decisions: + - Define queue chronology as successful admission order. + - Use typed structural redaction first and bounded text scanning second. + - Centralize native fixtures in existing feature-gated test infrastructure, not a new production crate. + - Extract internal transaction and reducer modules without changing wire or installed behavior. + - Split tests before adding ceilings so counts prove semantic preservation. +- Assumptions and risks: + - Other unpublished installations using `[workshop.stt]` will fail validation after removal. That break is intentional for the selected pre-1.0 scope. + - Bounded logging deliberately permits loss during permanent sink stalls; summaries and emergency diagnostics are part of the contract. + - A validated capability expands `shared-sidecar` public API but narrows Workshop mutation authority. + - External runner provisioning may already pin Rust; repository checks must match the actual service image before enforcement. + +## Project survey + +- Build commands: + - Prerequisite: Rust 1.89 (pinned in `rust-toolchain.toml` and workspace `rust-version`) and Node.js 22; run `npm ci` once per checkout in `crates/workshop-server/ui` and `crates/gateway-config-ui/ui`. + - `cargo build` builds the default workspace member `gateway`, including default-on `config-ui`, `local`, `web-search`, and `stt` features. + - `cargo build -p workshop` builds the Tauri desktop product and its in-process `workshop-server`. + - UI bundles build independently with `npm run build` in each UI directory; crate `build.rs` scripts invoke esbuild and place bundles in `OUT_DIR` (nothing UI-built is checked in). + - `cargo run -p build-user-guide` regenerates `guide/src/SUMMARY.md`, per-part landing pages, and the four single-file guide exports. +- Focused test command patterns: + - Rust unit or named test: `cargo test -p `. + - Rust integration harness: `cargo test -p --test it `. Gateway, `gateway-stt`, and `workshop-server` use `tests/it/main.rs` as the harness with responsibility-named modules below it. + - STT architecture gates: `node tools/check-stt-architecture.test.mjs`, `node tools/check-stt-architecture.mjs`, and `cargo test -p gateway-stt --test it architecture`. + - STT feature-gated fixtures: `cargo test -p gateway-stt -F test-fixtures`, `cargo test -p gateway-stt-engine -F test-fixtures`, `cargo test -p gateway-stt-backend-whisper -F test-fixtures`. + - Native Whisper tests are `#[ignore]` by default: same package command with `-- --ignored --test-threads=1`; require `PROMPTFORGE_WHISPER_LIBRARY` (or model/audio overrides) plus gitignored fixtures under `local/stt-fixtures/` or caller-specific fallback roots. + - Miri (pure STT ownership): `cargo +nightly-2026-09-05 miri test -p gateway-stt-engine -F test-fixtures miri_` and the same for `gateway-stt`. + - Workshop UI focused tests run from `crates/workshop-server/ui`, for example `node --test test/stt-stream.mjs`; package discovery is `npm test`. + - Config UI focused tests run from `crates/gateway-config-ui/ui` with `node --test src/.test.mjs`; `npm test` runs the full discovered suite after `pretest` runs `check-layers.mjs`. + - Node repository tools: `node --test tools/check-stt-architecture.test.mjs`, `node tools/check-stt-native-workflow.test.mjs`, and `node tools/stage-gateway-sidecar.test.mjs`. + - Gateway-logging latency budget: `cargo test -p gateway-logging --release -- --ignored`. +- Full-suite test commands: + - Rust workspace (CI Linux split): `cargo test --locked --workspace --exclude workshop --exclude workshop-server --all-features`, then `cargo test --locked -p workshop -p workshop-server` on Windows after staging the gateway sidecar with `node tools/stage-gateway-sidecar.mjs`. + - Workshop UI: from `crates/workshop-server/ui`, run `npm run typecheck`, `npm run build`, then `npm test` as separate commands. + - Config UI: from `crates/gateway-config-ui/ui`, run `npm run typecheck`, `npm run build`, then `npm test` (tests import built `dist/app.js`, so build precedes test). + - MSRV job: `cargo build --locked --workspace --exclude workshop --exclude workshop-server --all-features` and `cargo test --locked --workspace --exclude workshop --exclude workshop-server --all-features` on Rust 1.89.0. +- Linter and formatter commands: + - Rust formatting: `cargo fmt --all --check`. + - Rust linting: `cargo clippy --workspace --all-targets --all-features -- -D warnings`; CI excludes `workshop` and `workshop-server` in the Linux job and lints those two packages separately on Windows. + - Documentation gates: `cargo test --workspace --all-features --doc` and `RUSTDOCFLAGS=-D warnings cargo doc --workspace --no-deps --all-features`. + - Feature boundary gate: `cargo check -p gateway --no-default-features`. + - Supply chain: `cargo deny check` and `cargo audit`. + - Workshop UI layering and types: `npm run typecheck` (`tsc --noEmit` plus `check-layers.mjs`). Config UI runs `check-layers.mjs` through `npm test`. Neither UI package defines a standalone formatter command. +- Test placement and naming: + - Rust unit tests are colocated in source modules under `#[cfg(test)]`; async tests use `#[tokio::test]`. + - Cross-module and socket tests live under `tests/it/`, with shared fixtures in `tests/common/`. Test function names are lower snake case behavior statements. + - Native, Miri-filtered, and large-download tests are explicitly `#[ignore]` or gated with `#[cfg(not(miri))]` and name their required fixture or live dependency. + - Workshop UI tests are either `ui/test/**/*.mjs` or colocated `ui/src/**/*.test.mjs`. Names are plain English behavior statements; disposable-owning tests use `test/helpers/leak-check.mjs`. + - Node repository tool tests colocate as `tools/*.test.mjs` beside their drivers. + - Module size ratchets: `module-ceilings.toml` in `gateway-stt`, `gateway-stt-engine`, `gateway-stt-backend-whisper`, `gateway-whisper-ffi`, and `workshop-server`, enforced by crate integration tests (for example `cargo test -p workshop-server --test it ratchet`). +- Directory map: + - `.cargo/` holds repository Cargo configuration; `.github/` holds CI, release, nightly, STT/Miri, and guide workflows plus reusable actions. + - `crates/` is the product and library workspace. Gateway speech code lives in `gateway-stt`, `gateway-stt-engine`, `gateway-stt-backend-whisper`, and `gateway-whisper-ffi`. + - Gateway product crates: `gateway`, `gateway-config`, `gateway-config-ui`, `gateway-local`, `gateway-logging`, `gateway-routing`, `gateway-stt`, `gateway-web-search`, `gateway-whisper-ffi`. + - Workshop product crates: `workshop`, `workshop-server`; the browser application is `crates/workshop-server/ui`; config UI sources are `crates/gateway-config-ui/ui`. + - Cross-product substrate: `shared-loopback`, `shared-progress`, `shared-protocol`, `shared-sidecar`, and the non-Rust `shared-ui` package. + - PromptForge library crates use the `promptforge-*` prefix; `build-*` crates (`build-ui`, `build-user-guide`, `build-llama-cuda`) are compile-time or CI tooling linked into no deliverable. + - `design/` holds design material; `guide/` holds mdBook documentation; `local/` holds gitignored developer fixtures; `prompts/` holds prompt programs; `tools/` holds repository Node gates; `vibe/` holds execution plans, `vibe/archdoc.md`, and the architecture queue. +- Component boundaries (from `vibe/archdoc.md` and current manifests): + - executor (`promptforge`, `promptforge-core`, parser, store, Lua, agent, tools): depends on gateway protocol, store, shared substrate. + - gateway (`gateway`, `gateway-config`, `gateway-local`, `gateway-logging`, `gateway-routing`, `gateway-stt`, `gateway-web-search`): independent server process; sole holder of vendor credentials (A2); depends on shared substrate only among cross-product crates. + - workshop UI (`workshop`, `workshop-server`, `workshop-server/ui`): desktop shell hosts `workshop-server` in-process and attaches to gateway through `shared-sidecar`; depends on executor support crates and shared substrate, not on `gateway-stt`. + - STT stack: `gateway-stt` orchestrates HTTP/WebSocket speech routes and session state; depends on `gateway-stt-engine` (backend-neutral workers) and `gateway-stt-backend-whisper` (Whisper policy), which depends on `gateway-whisper-ffi` (runtime-loaded ABI leaf). No STT crate depends on `workshop-server`. + - Realtime paths at endpoint: `gateway/tests/it/realtime_stt.rs`, `gateway-stt/tests/it/realtime_session.rs` and `realtime_fixtures.rs`, `workshop-server/src/routes/realtime.rs`, `workshop-server/tests/it/realtime_relay.rs` and `chat_gate.rs`, `workshop-server/ui/src/services/realtime-transcription.ts`, `workshop-server/ui/src/ui/realtime-stt.ts`. + - Sidecar seam: `shared-sidecar` is the sole connection-file implementation; gateway writes, workshop and workshop-server read. + - Logging: `gateway-logging` is consumed only by `gateway`; queue, rotation, redaction, and worker thread stay inside that crate. + - Config: `gateway-config` owns validated TOML; version 2 uses top-level `[stt]` with a legacy `[workshop.stt]` migration shim still present in Rust and the config UI (debt target for this plan). +- Visible conventions: + - Crate prefixes encode product membership (`gateway*`, `promptforge*`, bare `workshop*`, `shared-*`, `build-*`). Shared dependencies must live in `shared-*`; build-only tooling in `build-*`. + - Cargo features gate real constraints only. Gateway `local`, `web-search`, `config-ui`, and `stt` features are additive and default on; `cargo check -p gateway --no-default-features` must stay green. + - Rust modules are private by default with deliberate crate-root re-exports. Every public item requires rustdoc; libraries use typed errors; behavior changes ship with tests in the same change. + - Runtime paths never compile native code. Whisper loads from packaged runtime artifacts; unsafe, ABI layouts, and raw pointers stay in `gateway-whisper-ffi`. + - Workshop server route groups expose `fn routes(state) -> Router`; `app.rs` composes them. One task owns each ordinary socket; request and session errors are values; in-process tests use `Router::oneshot` or spawn fixtures. + - Workshop UI imports flow `ui -> services -> base`; `main.ts` is the composition root. The rule is enforced by `check-layers.mjs` during build, typecheck, and Cargo bundling. + - Generated UI bundles and architecture-owned guide files are never checked in. STT public surfaces and module sizes are ratcheted through `tools/check-stt-architecture.mjs` and per-crate `module-ceilings.toml`. + - Plans and nested `AGENTS.md` files bind sub-agents; root `AGENTS.md` states workspace-wide rules that nested files do not restate. +- Rules manifest: + - `AGENTS.md` governs the repository root. + - `crates/gateway/AGENTS.md` governs `crates/gateway/`. + - `crates/gateway-config/AGENTS.md` governs `crates/gateway-config/`. + - `crates/gateway-local/AGENTS.md` governs `crates/gateway-local/`. + - `crates/gateway-logging/AGENTS.md` governs `crates/gateway-logging/`. + - `crates/gateway-routing/AGENTS.md` governs `crates/gateway-routing/`. + - `crates/gateway-stt/AGENTS.md` governs `crates/gateway-stt/`. + - `crates/gateway-stt-engine/AGENTS.md` governs `crates/gateway-stt-engine/`. + - `crates/gateway-stt-backend-whisper/AGENTS.md` governs `crates/gateway-stt-backend-whisper/`. + - `crates/gateway-web-search/AGENTS.md` governs `crates/gateway-web-search/`. + - `crates/gateway-whisper-ffi/AGENTS.md` governs `crates/gateway-whisper-ffi/`. + - `crates/promptforge/AGENTS.md` governs `crates/promptforge/`. + - `crates/promptforge-agent/AGENTS.md` governs `crates/promptforge-agent/`. + - `crates/promptforge-core/AGENTS.md` governs `crates/promptforge-core/`. + - `crates/promptforge-core-support/AGENTS.md` governs `crates/promptforge-core-support/`. + - `crates/promptforge-lua/AGENTS.md` governs `crates/promptforge-lua/`. + - `crates/promptforge-model-client/AGENTS.md` governs `crates/promptforge-model-client/`. + - `crates/promptforge-parser/AGENTS.md` governs `crates/promptforge-parser/`. + - `crates/promptforge-store/AGENTS.md` governs `crates/promptforge-store/`. + - `crates/promptforge-tools/AGENTS.md` governs `crates/promptforge-tools/`. + - `crates/promptforge-web-search/AGENTS.md` governs `crates/promptforge-web-search/`. + - `crates/promptforge-webfetch/AGENTS.md` governs `crates/promptforge-webfetch/`. + - `crates/shared-loopback/AGENTS.md` governs `crates/shared-loopback/`. + - `crates/shared-progress/AGENTS.md` governs `crates/shared-progress/`. + - `crates/shared-protocol/AGENTS.md` governs `crates/shared-protocol/`. + - `crates/shared-sidecar/AGENTS.md` governs `crates/shared-sidecar/`. + - `crates/shared-ui/AGENTS.md` governs `crates/shared-ui/`. + - `crates/workshop/AGENTS.md` governs `crates/workshop/`. + - `crates/workshop/icons/AGENTS.md` additionally governs `crates/workshop/icons/`. + - `crates/workshop-server/AGENTS.md` governs `crates/workshop-server/`. + - `crates/workshop-server/ui/AGENTS.md` additionally governs `crates/workshop-server/ui/`. + +## Execution Instructions + +### Step 1: Split Gateway Realtime integration coverage [completed] + +- Component and piece: Component 1 of 8, regression boundaries; first split the Gateway Realtime suite by authentication, protocol, lifecycle, recovery, overload, and canonical sequence while preserving every discovered test. +- Dependency: starts from the plan seed because later Gateway and Realtime refactors need stable concern-level test homes and a recorded pre-refactor test count. +- Debt IDs: `PF-RTSTT-DC-006`. +- Artifacts: `crates/gateway/tests/it/realtime_stt.rs`, `crates/gateway/tests/it/realtime_stt/*.rs`, `crates/gateway/tests/it/main.rs`, and focused support extracted only when shared by the new files. +- Scope: move tests without changing assertions, fixtures, ignored status, or production behavior; verify the discovered test count before and after the split without adding a persistent ratchet yet. +- Exclusions: no profile-switch, decoder, fixture-resolution, or production changes; unrelated defects are recorded separately. +- Focused verification: from the repository root run `cargo test -p gateway`; compare the ratchet's recorded count with the passing discovered suite. + +### Step 2: Split Workshop relay integration coverage [completed] + +- Component and piece: Component 1 of 8, regression boundaries; split Workshop `realtime_relay` and `chat_gate` coverage by authentication, protocol, lifecycle, recovery, overload, and canonical sequence while preserving every discovered test. +- Dependency: depends on Step 1 only for one consistent count-preserving split convention; it must precede Workshop decoder, reducer, supervisor, and sidecar changes so moved assertions retain stable ownership. +- Debt IDs: `PF-RTSTT-DC-006`. +- Artifacts: `crates/workshop-server/tests/it/realtime_relay.rs`, `crates/workshop-server/tests/it/realtime_relay/*.rs`, `crates/workshop-server/tests/it/chat_gate.rs`, `crates/workshop-server/tests/it/chat_gate/*.rs`, and `crates/workshop-server/tests/it/main.rs`. +- Scope: move tests and narrowly shared fixtures without semantic edits; verify exact before and after counts for both source suites without adding persistent ratchets yet. +- Exclusions: no production relay, session-agent, UI, Gateway binding, or sidecar behavior changes. +- Focused verification: from the repository root run `cargo test -p workshop-server`. + +### Step 3: Enforce integration test file ceilings [completed] + +- Component and piece: Component 1 of 8, regression boundaries; add one repository gate for physical-line ceilings and exact test-count records for the three split suites. +- Dependency: depends on Steps 1 and 2 because the selected decision is to split first, prove count preservation, and only then freeze the resulting concern boundaries. +- Debt IDs: `PF-RTSTT-DC-006`. +- Artifacts: create `tools/check-integration-test-ceilings.mjs`, `tools/check-integration-test-ceilings.test.mjs`, and `tools/integration-test-ceilings.json` covering `crates/gateway/tests/it/realtime_stt/`, `crates/workshop-server/tests/it/realtime_relay/`, and `crates/workshop-server/tests/it/chat_gate/`; wire the gate in `.github/workflows/ci.yml`. +- Scope: enforce physical-line ceilings, exact manifest coverage, and recorded test totals with path-normalized tests. +- Exclusions: do not impose ceilings on unrelated suites or alter any production module ceiling. +- Focused verification: from the repository root run `node tools/check-integration-test-ceilings.test.mjs`, `node tools/check-integration-test-ceilings.mjs`, `cargo test -p gateway`, and `cargo test -p workshop-server`. +- Component boundary: ends Component 1; review cumulative Steps 1 through 3 against the pre-Step-1 base. + +### Step 4: Bound formatted logging records [completed] + +- Component and piece: Component 2 of 8, `gateway-logging`; establish one immutable limits object and bounded record formatting with a valid-text truncation marker. +- Dependency: depends on Step 3 only as the completed regression foundation; within logging it is first because queue, wait, shutdown, and segment budgets consume the same limits object. +- Debt IDs: `PF-GWLOG-001`, with contract input for `PF-GWLOG-004` and `PF-GWLOG-005`. +- Artifacts: `crates/gateway-logging/src/config.rs`, `writer.rs`, `queue.rs`, `lib.rs`, and their unit tests. +- Scope: define maximum formatted record bytes, aggregate queued bytes, producer wait, shutdown wait, segment bytes, and aggregate retained bytes; bound formatting and count rejected or truncated records in the same observable loss episode. +- Exclusions: no queue-order change, producer timeout, disk rotation, or redaction expansion yet; log message content otherwise stays unchanged. +- Focused verification: from the repository root run `cargo test -p gateway-logging`. + +### Step 5: Order and account the logging queue [completed] + +- Component and piece: Component 2 of 8, `gateway-logging`; make queue admission enforce aggregate bytes, assign sequence under the mutex, and close one pressure episode at a defined low-water transition. +- Dependency: depends on Step 4 because admission must use the shared record and aggregate byte limits and its loss accounting; it precedes timeout work because wait outcomes need final admission semantics. +- Debt IDs: `PF-GWLOG-001`, `PF-GWLOG-002`, `PF-GWLOG-003`. +- Artifacts: `crates/gateway-logging/src/queue.rs`, `writer.rs`, and queue concurrency tests. +- Scope: preserve lane priority inside one successful-admission order, enforce strict peak queued bytes, and emit exactly one summary with dropped and truncated counts per pressure episode. +- Exclusions: no indefinite retention guarantee, sink implementation change, segment rotation, or redaction work. +- Focused verification: from the repository root run `cargo test -p gateway-logging`. + +### Step 6: Bound logging stalls and shutdown [completed] + +- Component and piece: Component 2 of 8, `gateway-logging`; apply the selected bounded-loss policy to protected producers, sink stalls, and runtime shutdown. +- Dependency: depends on Step 5 because finite waits must terminate in the queue's explicit loss-accounting path and preserve successful-admission order. +- Debt IDs: `PF-GWLOG-005`, plus `PF-GWLOG-003` loss observability. +- Artifacts: `crates/gateway-logging/src/queue.rs`, `worker.rs`, `runtime.rs`, `crates/gateway/src/main.rs`, and stalled-sink and saturated-shutdown tests. +- Scope: protected producers wait only for the configured budget and then record loss through a preallocated path; shutdown waits only for its budget, attempts an emergency diagnostic, and detaches rather than joining forever. +- Exclusions: no emergency spool, no unbounded protected-record retention, no unrelated Gateway shutdown redesign, and no claim of lossless logging during a permanent stall. +- Focused verification: from the repository root run `cargo test -p gateway-logging` and `cargo test -p gateway`. + +### Step 7: Rotate fixed-size log segments [completed] + +- Component and piece: Component 2 of 8, `gateway-logging`; replace run-count-only retention with fixed-size segments under one aggregate disk-byte budget. +- Dependency: depends on Step 4 for segment and aggregate budgets and on Step 5 for bounded terminal records; it is independent of Step 6 behavior but follows it to avoid overlapping worker and runtime edits. +- Debt IDs: `PF-GWLOG-004`. +- Artifacts: `crates/gateway-logging/src/config.rs`, `worker.rs`, `runtime.rs`, and rotation tests. +- Scope: retain `gateway.log` and numbered diagnostic names, reserve marker and terminal-record space, prune oldest segments before admission, and prove active plus retained bytes stay within budget. +- Exclusions: no per-run discard, no prune-only active-file strategy, and no rename of diagnostic files. +- Focused verification: from the repository root run `cargo test -p gateway-logging`. + +### Step 8: Redact structured logging fields [completed] + +- Component and piece: Component 2 of 8, `gateway-logging`; classify and redact structured fields and secret types before formatting, with the bounded textual scanner retained as defense in depth. +- Dependency: depends on Step 4 because pre-format output must honor the bounded writer and on Step 5 because rejected or truncated records share loss accounting; it follows Steps 6 and 7 to minimize conflicting edits. +- Debt IDs: `PF-GWLOG-006`. +- Artifacts: `crates/gateway-logging/src/redact.rs`, `writer.rs`, `lib.rs`, and their adversarial redaction tests. +- Scope: cover Basic and Bearer authorization, cookies, URLs, prompts, paths, payloads, multiline and nested errors, and classified credential fields without persisting protected values. +- Exclusions: no unrelated log wording changes and no unbounded scanner or second sink. +- Focused verification: from the repository root run `cargo test -p gateway-logging`, `cargo test -p gateway`, and `cargo test -p gateway-logging --release -- --ignored` for the existing latency budget. +- Component boundary: ends Component 2; review cumulative Steps 4 through 8 against the Step 3 commit. + +### Step 9: Remove both legacy STT config shims [completed] + +- Component and piece: Component 3 of 8, version-2 configuration; delete both compatibility paths in one atomic behavior change. +- Dependency: depends only on the regression foundation ending at Step 3 and is intentionally independent of logging; Rust and TypeScript must land together so no layer continues accepting `[workshop.stt]`. +- Debt IDs: `STT-CORE-001`. +- Artifacts: `crates/gateway-config/src/config/imp.rs`, `config/accessors.rs`, `config/tests/schema.rs`, `config/tests/serialize.rs`, `config/tests/validation.rs`, `crates/gateway-config-ui/ui/src/services/config-store.ts`, `src/services/config-store.test.mjs`, and `src/views/settings-sections.test.mjs`. +- Scope: keep `config-version = 2`, delete `migrate_legacy_stt` and `canonicalizeStt` immediately, reject legacy and mixed forms through unknown-field validation, and leave canonical `[stt]` parsing and UI serialization unchanged. Verify `C:\Users\Vinnie\.promptforge\gateway.toml` has no legacy section, record its SHA-256, perform no write to it, and prove the already-canonical file is byte-for-byte identical afterward. +- Exclusions: no migration command, schema version 3, deprecation window, automatic rewrite, or repair of the canonical local file. +- Focused verification: from the repository root run `cargo test -p gateway-config`; from `crates/gateway-config-ui/ui` run `npm run typecheck`, `npm run build`, and `npm test`; in PowerShell compare `Get-FileHash C:\Users\Vinnie\.promptforge\gateway.toml -Algorithm SHA256` before and after the read-only local check. +- Component boundary: ends Component 3; review Step 9 against the Step 8 commit, including the paired Rust and TypeScript deletion and the local hash evidence. + +### Step 10: Centralize native STT fixture resolution [completed] + +- Component and piece: Component 4 of 8, STT test infrastructure; replace the five resolver copies with the existing feature-gated `gateway-stt-engine` fixture boundary and explicit caller fallback roots. +- Dependency: depends on Step 3's stable test layout; it precedes API snapshots because the canonical resolver surface must exist before its feature-enabled contract is recorded. +- Debt IDs: `STT-CORE-002`. +- Artifacts: create `crates/gateway-stt-engine/src/test_fixtures/native.rs`; update `crates/gateway-stt/tests/common/mod.rs`, `crates/gateway-stt-backend-whisper/src/prompt.rs`, `crates/gateway-stt-backend-whisper/tests/native_whisper.rs`, `crates/gateway/tests/it/realtime_stt/`, and affected `Cargo.toml` feature wiring. +- Scope: expose one non-production resolver, preserve `PROMPTFORGE_WHISPER_LIBRARY`, `PROMPTFORGE_WHISPER_MODEL`, and `PROMPTFORGE_WHISPER_AUDIO`, and require each caller to pass its fallback root explicitly. +- Exclusions: no fifth production STT crate, no installed speech behavior change, and no native fixture download redesign. +- Focused verification: from the repository root run `cargo test -p gateway-stt -F test-fixtures`, `cargo test -p gateway-stt-backend-whisper -F test-fixtures`, and `cargo test -p gateway`. + +### Step 11: Ratchet feature-enabled fixture APIs [completed] + +- Component and piece: Component 4 of 8, STT test infrastructure; measure and freeze the feature-enabled public surfaces before narrowing them. +- Dependency: depends on Step 10 because snapshots must describe the centralized API, and it must precede Step 12 so narrowing has an explicit reviewed baseline. +- Debt IDs: `STT-CORE-003`. +- Artifacts: `tools/check-stt-architecture.mjs`, `tools/check-stt-architecture.test.mjs`, `crates/gateway-stt/public-api-test-fixtures.txt`, `crates/gateway-stt-engine/public-api-test-fixtures.txt`, and both crates' `module-ceilings.toml` records. +- Scope: make unreviewed fixture API growth fail while proving default builds expose no fixture symbols. +- Exclusions: no production public API expansion and no low-level fixture removal in this baseline step. +- Focused verification: from the repository root run `node tools/check-stt-architecture.test.mjs`, `node tools/check-stt-architecture.mjs`, `cargo test -p gateway-stt -F test-fixtures`, and `cargo test -p gateway-stt-engine -F test-fixtures`. + +### Step 12: Narrow fixture APIs to scenarios [completed] + +- Component and piece: Component 4 of 8, STT test infrastructure; replace consumer-visible synchronization controls with scenario-level fixture operations. +- Dependency: depends on Step 11 because every current consumer and feature-enabled symbol must be inventoried and snapshotted before contraction. +- Debt IDs: `STT-CORE-003`. +- Artifacts: `crates/gateway-stt/src/test_fixtures.rs`, `crates/gateway-stt-engine/src/test_fixtures.rs`, their consumer tests, `crates/gateway-stt/public-api-test-fixtures.txt`, `crates/gateway-stt-engine/public-api-test-fixtures.txt`, and both crates' `module-ceilings.toml`. +- Scope: preserve all tested scenarios while reducing the quasi-public control surface and updating exact snapshots downward. +- Exclusions: no production behavior changes, no new feature, and no weakened Miri ownership or queue coverage. +- Focused verification: from the repository root run `cargo test -p gateway-stt -F test-fixtures`, `cargo test -p gateway-stt-engine -F test-fixtures`, `cargo +nightly-2026-09-05 miri test -p gateway-stt -F test-fixtures`, `cargo +nightly-2026-09-05 miri test -p gateway-stt-engine -F test-fixtures`, and `node tools/check-stt-architecture.mjs`. + +### Step 13: Restore dead-code diagnostics [completed] + +- Component and piece: Component 4 of 8, STT test infrastructure; remove broad dead-code allowances and resolve only actual configuration-specific exceptions. +- Dependency: depends on Step 12 because narrowing fixture symbols first prevents allowances from masking obsolete controls. +- Debt IDs: `STT-CORE-004`. +- Artifacts: `crates/gateway-stt/src/lib.rs`, affected feature-gated modules, focused tests, and item-level annotations only where configuration evidence requires them. +- Scope: keep dead-code diagnostics active under default, all-feature, test, Miri, and featureless configurations. +- Exclusions: no module-wide allowance, speculative use site, or unrelated warning cleanup. +- Focused verification: from the repository root run `cargo test -p gateway-stt`, `cargo test -p gateway-stt -F test-fixtures`, `cargo clippy -p gateway-stt --all-targets --all-features -- -D warnings`, and `cargo check -p gateway --no-default-features`. + +### Step 14: Pin the native STT runner contract [completed] + +- Component and piece: Component 4 of 8, STT test infrastructure; enforce one exact Rust toolchain and versioned self-hosted runner layout before cache or native work. +- Dependency: depends on Step 10 for the final native fixture contract and follows Steps 11 through 13 so the workflow validates the settled test surface. +- Debt IDs: `STT-CORE-005`. +- Artifacts: `.github/workflows/stt-miri.yml` and `tools/check-stt-native-workflow.test.mjs`. +- Scope: set `RUSTUP_TOOLCHAIN` to `1.89`, resolve `rustup`, `cargo`, and `rustc` from the provisioned runner `PATH`, require exact Rust `1.89.0` before cache use, and keep the hosted Miri nightly pinned and all native Whisper jobs on the Windows CUDA runner. +- Exclusions: no floating `stable`, `$USERPROFILE\.cargo\bin` assumption, runner reprovisioning from CI, or change to native fixture hashes. +- Focused verification: from the repository root run `node tools/check-stt-native-workflow.test.mjs`, `cargo test -p gateway-stt`, and `cargo test -p gateway-stt-backend-whisper`. +- Component boundary: ends Component 4; review cumulative Steps 10 through 14 against the Step 9 commit. + +### Step 15: Make preparation names collision-resistant [completed] + +- Component and piece: Component 5 of 8, Gateway profile switching; harden prepared persistence names before moving transaction ownership. +- Dependency: depends on Step 1's split Gateway coverage and is the first profile-switch piece because the transaction must inherit settled temporary-file ownership semantics. +- Debt IDs: `PF-RTSTT-DC-002`. +- Artifacts: `crates/gateway/src/config_write.rs`, its `PreparedFile` tests, and relevant profile-switch integration tests under `crates/gateway/tests/it/profiles.rs`. +- Scope: add one process-random nonce and bounded `create_new` retry, test deterministic collisions and crash residue, and delete residue only when ownership is proven. +- Exclusions: no broad temporary-file cleanup, rollback redesign, config format change, or deletion of unproven residue. +- Focused verification: from the repository root run `cargo test -p gateway`. + +### Step 16: Extract profile preparation phases [completed] + +- Component and piece: Component 5 of 8, Gateway profile switching; create a private transaction module for target, cancellation, prepared persistence, prior runtime snapshot, and prepared and cutover phase values. +- Dependency: depends on Step 15 because moved preparation must use the final collision and ownership contract; it precedes terminal phases so tests can pin preparation and cutover independently. +- Debt IDs: `PF-RTSTT-DC-001`. +- Artifacts: create `crates/gateway/src/profile_switch.rs`; move `PreparedPersistence`, `CutoverState`, `prepare_cutover`, persistence helpers, and their tests from `crates/gateway/src/lib.rs` and `config_write.rs`. +- Scope: preserve locks, cancellation points, persistence ordering, old-runtime capture, and external behavior while making invalid preparation and cutover order unrepresentable. +- Exclusions: no wire change, installed behavior change, new lock, terminal commit rewrite, or unrelated reduction of the root module. +- Focused verification: from the repository root run `cargo test -p gateway`. + +### Step 17: Complete the profile-switch transaction [completed] + +- Component and piece: Component 5 of 8, Gateway profile switching; represent staged, committed, rolled-back, indeterminate, and terminal outcomes as values and delegate root orchestration to the transaction. +- Dependency: depends on Step 16 because terminal transitions consume the prepared and cutover phase values and their owned rollback state. +- Debt IDs: `PF-RTSTT-DC-001`. +- Artifacts: `crates/gateway/src/profile_switch.rs`, `crates/gateway/src/lib.rs`, `config_write.rs`, `config_apply.rs`, and profile-switch unit and integration tests. +- Scope: preserve every cancellation, rollback, indeterminate-persistence, atomic-publication, speech-replacement, and featureless path; move only helpers owned by this transaction. +- Exclusions: no Gateway API change, no altered timeout policy, no profile schema change, and no cleanup outside the extracted responsibility. +- Focused verification: from the repository root run `cargo test -p gateway`, `cargo check -p gateway --no-default-features`, and `cargo clippy -p gateway --all-targets --all-features`. +- Component boundary: ends Component 5; review cumulative Steps 15 through 17 against the Step 14 commit and update architecture records only for transaction facts now present. + +### Step 18: Decode Realtime events exhaustively [completed] + +- Component and piece: Component 6 of 8, Workshop Realtime UI; introduce one pure exhaustive decoder used by production and canonical fixture mutation tests. +- Dependency: depends on Step 2's stable Workshop integration boundaries and precedes reducer work because the reducer may accept only typed trusted events. +- Debt IDs: `PF-RTSTT-DC-003`. +- Artifacts: `crates/workshop-server/ui/src/services/realtime-transcription.ts`, create `src/services/realtime-event-decoder.ts`, and update `test/realtime-wire-fixtures.mjs` and `test/stt-stream.mjs`. +- Scope: return a discriminated union after exact validation of required and nullable fields, IDs, content index, revision, transcript partition, audio spans, completion usage, and unsupported event types; production and canonical sequence tests call the same decoder. +- Exclusions: no speech protocol change, relay change, reconnect policy change, or dictation ownership refactor. +- Focused verification: from `crates/workshop-server/ui` run `npm run typecheck`, `npm run build`, and `npm test`. + +### Step 19: Move insertion policy into input targets [completed] + +- Component and piece: Component 6 of 8, Workshop Realtime UI; give each `SttInputTarget` one insertion-context operation. +- Dependency: depends on Step 18 only for settled typed service inputs and precedes the registry because composition policy must leave lifecycle state before reducer extraction. +- Debt IDs: `DC-PF-P2-005`. +- Artifacts: `crates/workshop-server/ui/src/ui/stt.ts`, `prompt-input.ts`, textarea target code, `test/prompt-input.mjs`, and `test/stt-stream.mjs`. +- Scope: `insertionContext` returns the selected range, original text, and immutable required prefix for textarea and ProseMirror targets. +- Exclusions: no editor replacement, document-wide read in the registry, transcript reducer, or visual behavior change. +- Focused verification: from `crates/workshop-server/ui` run `npm run typecheck`, `npm run build`, and `npm test`. + +### Step 20: Build the pure TakeRegistry reducer [completed] + +- Component and piece: Component 6 of 8, Workshop Realtime UI; extract pure take state and transitions before production wiring. +- Dependency: depends on Step 18 for typed events and Step 19 for target-owned insertion context, which together define all reducer inputs. +- Debt IDs: `PF-RTSTT-DC-004`, `DC-PF-P2-005`. +- Artifacts: create `crates/workshop-server/ui/src/ui/take-registry.ts` and `test/take-registry.mjs`; use types from `realtime-event-decoder.ts` and `stt.ts`. +- Scope: model overlap, tombstones, precommit binding, rollback, reconnect, sequential spacing, completion authority, and selection replacement; emit editor, capture, status, and wire effects without performing them. +- Exclusions: no DOM, socket, capture-service, status-service, or document-structure access inside the reducer and no production wiring yet. +- Focused verification: from `crates/workshop-server/ui` run `npm run typecheck`, `npm run build`, and `npm test`. + +### Step 21: Wire production through TakeRegistry [completed] + +- Component and piece: Component 6 of 8, Workshop Realtime UI; make `setupStt` interpret reducer effects and remove the callback-owned maps, sets, flags, and editor offsets. +- Dependency: depends on Step 20 because production wiring must consume a fully tested pure transition surface rather than define state transitions in callbacks. +- Debt IDs: `PF-RTSTT-DC-004`, `DC-PF-P2-005`. +- Artifacts: `crates/workshop-server/ui/src/ui/realtime-stt.ts`, `take-registry.ts`, `test/stt-stream.mjs`, `test/prompt-input.mjs`, and affected UI boot tests. +- Scope: preserve capture, wire, status, textarea, ProseMirror, rollback, reconnect, and spacing behavior while giving the registry exclusive take ownership. +- Exclusions: no protocol decoder change after Step 18, no editor internals in lifecycle code, and no unrelated UI cleanup. +- Focused verification: from `crates/workshop-server/ui` run `npm run typecheck`, `npm run build`, and `npm test`. +- Component boundary: ends Component 6; review cumulative Steps 18 through 21 against the Step 17 commit and update architecture records only for decoder and reducer facts now present. + +### Step 22: Define agent-supervisor transitions [completed] + +- Component and piece: Component 7 of 8, Workshop agent supervision; build a pure event and transition model before changing the async loop. +- Dependency: depends on Step 21 only for prior component closure; it deliberately retains the existing `GatewayBinding` generation interface, which later sidecar publication changes must preserve. +- Debt IDs: `PF-RTSTT-DC-005`. +- Artifacts: create `crates/workshop-server/src/session_agents/supervisor/transition.rs`; update `session_agents/lifecycle.rs` and focused transition-table tests. +- Scope: model run completion, catalog generation, Gateway generation, operator cancellation, accepted input, and terminal settlement; decide wait, cancel, preserve, relaunch, or close effects. +- Exclusions: no async orchestration rewrite, model client change, catalog semantics change, or sidecar publication change in this step. +- Focused verification: from the repository root run `cargo test -p workshop-server`. + +### Step 23: Wire agent supervision through transitions [completed] + +- Component and piece: Component 7 of 8, Workshop agent supervision; reduce the async supervisor loop to event collection and effect execution. +- Dependency: depends on Step 22 because run and accepted-turn ownership must be decided by the tested transition table before branch interactions are removed. +- Debt IDs: `PF-RTSTT-DC-005`. +- Artifacts: `crates/workshop-server/src/session_agents/supervisor.rs`, `supervisor/catalog.rs`, `supervisor/transition.rs`, `lifecycle.rs`, and agent integration coverage under `crates/workshop-server/tests/it/agents.rs`. +- Scope: preserve delayed catalog handling, replacement during accepted input, operator cancel, retained history, close behavior, and exactly-once settlement. +- Exclusions: no Gateway binding representation change, catalog filtering change, protocol change, or unrelated session cleanup. +- Focused verification: from the repository root run `cargo test -p workshop-server`. +- Component boundary: ends Component 7; review cumulative Steps 22 and 23 against the Step 21 commit and update architecture records only for supervisor facts now present. + +### Step 24: Introduce ValidatedConnection [completed] + +- Component and piece: Component 8 of 8, sidecar trust and lifecycle; make successful validation produce a public but unforgeable capability. +- Dependency: depends on stable existing sidecar resolution tests and precedes all publication changes because raw files must become incapable of crossing the Workshop mutation boundary. +- Debt IDs: `DC-PF-P2-002`. +- Artifacts: create `crates/shared-sidecar/src/validated.rs`; update `lib.rs`, `stale.rs`, `file.rs`, `health.rs`, and their capability tests and public documentation. +- Scope: keep constructors private; prove process image, boot identity, health, and bearer acceptance; expose only redacted accessors and internal data needed to build a consumer snapshot. +- Exclusions: no caller-only validation, no public constructor, no secret-bearing debug output, and no shift of supervision ownership into `workshop-server`. +- Focused verification: from the repository root run `cargo test -p shared-sidecar` and `cargo doc -p shared-sidecar --no-deps`. + +### Step 25: Require capability-based Gateway publication + +- Component and piece: Component 8 of 8, sidecar trust and lifecycle; narrow the public updater and place validated identity in the immutable binding snapshot. +- Dependency: depends on Step 24 because `GatewayUpdater` must accept the unforgeable capability rather than revalidate or trust a raw `ConnectionFile`; it also supplies the authoritative identity consumed by Steps 26 and 27. +- Debt IDs: `DC-PF-P2-002`, `DC-PF-P2-003`. +- Artifacts: `crates/workshop-server/src/gateway_binding.rs`, `serve.rs`, `app.rs`, `lib.rs`, tests, and `crates/workshop/src/gateway.rs`. +- Scope: make `GatewayUpdater::replace_sidecar` accept only `ValidatedConnection`, remove raw-file publication from the public Workshop server API, and atomically publish clients plus validated identity in one `GatewayBinding` snapshot. +- Exclusions: no separate identity store, no per-process bearer rotation, no LAN Gateway shutdown authority, and no supervision move across components. +- Focused verification: from the repository root run `cargo test -p shared-sidecar`, `cargo test -p workshop-server`, and `cargo test -p workshop`. + +### Step 26: Route quit through the authoritative snapshot + +- Component and piece: Component 8 of 8, sidecar trust and lifecycle; remove duplicate Gateway identity ownership from the desktop shell. +- Dependency: depends on Step 25 because quit must read the same validated snapshot that current HTTP and model clients use, including after replacement. +- Debt IDs: `DC-PF-P2-003`. +- Artifacts: `crates/workshop/src/main.rs`, `menu.rs`, `gateway.rs`, `crates/workshop-server/src/gateway_binding.rs`, and replacement-raced-with-quit tests. +- Scope: remove `GatewaySlot`, target exactly one current validated local generation, and preserve explicit LAN Gateway behavior; prove same-port, same-key, and configured-key replacement remain atomic. +- Exclusions: no shutdown of configured LAN Gateways, no second identity cache, no credential rotation, and no menu redesign. +- Focused verification: from the repository root run `cargo test -p workshop-server` and `cargo test -p workshop`. + +### Step 27: Join cancellation-aware sidecar shutdown + +- Component and piece: Component 8 of 8, sidecar trust and lifecycle; make resolve, validation, wait, launch, supervision, and publication cancellation-aware and finitely joined. +- Dependency: depends on Steps 25 and 26 because cancellation must prevent publication into the authoritative snapshot and quit must target that same snapshot. +- Debt IDs: `DC-PF-P2-001`. +- Artifacts: `crates/workshop/src/gateway.rs`, `main.rs`, `crates/shared-sidecar/src/stale.rs`, `health.rs`, `lock.rs`, and blocking-phase tests in those modules. +- Scope: `GatewaySupervisor` owns and joins its thread under a finite shutdown budget; tests block each phase, request Workshop exit, and prove bounded termination with no later launch, probe, or publication. +- Exclusions: no abandoned supervisor thread, unbounded join, process kill, emergency supervisor, or change to separate Gateway process ownership. +- Focused verification: from the repository root run `cargo test -p shared-sidecar`, `cargo test -p workshop-server`, and `cargo test -p workshop`. + +### Step 28: Split and ratchet sidecar lifecycle ownership + +- Component and piece: Component 8 of 8, sidecar trust and lifecycle; separate boot planning and one-shot launch from continuous supervision, validated identity, and recovery tests, then freeze the new boundaries. +- Dependency: depends on Step 27 because the selected order is to settle cancellation and joined ownership before extracting modules and recording their final ceilings; it is last because full exit gates may run only after every debt ID is closed. +- Debt IDs: `DC-PF-P2-004`, with closure verification for every debt ID in this plan. +- Artifacts: `crates/workshop/src/gateway.rs`; create `crates/workshop/src/gateway/boot.rs`, `supervisor.rs`, `identity.rs`, `tests/boot.rs`, `tests/recovery.rs`, and `tests/identity.rs`; add `crates/workshop/module-ceilings.toml` and `crates/workshop/tests/module_ceiling.rs`; append settled implementation facts only to `vibe/archdoc-next.md`, and update public documentation only for facts settled by Steps 15 through 28. Never edit `vibe/archdoc.md` during the run. +- Scope: preserve boot, launch, validation, recovery, publication, and shutdown behavior; record ceilings for every resulting module; run focused tests first, then formatting, workspace lint, workspace tests, documentation, architecture, feature-enabled API, native Whisper, both Miri, both UI, guide-generation, unsigned-package recovery, and signed-release gates. +- Exclusions: no pre-documentation of planned APIs, unrelated debt cleanup, component ownership reassignment, speech behavior change, or expansion beyond defects introduced, worsened, or exposed by this plan. +- Focused verification: from the repository root run `cargo test -p shared-sidecar -p workshop-server -p workshop`, `cargo fmt --all --check`, `cargo clippy --workspace --all-targets --all-features -- -D warnings`, `cargo test --workspace --locked`, `cargo doc --workspace --no-deps`, `node tools/check-stt-architecture.test.mjs`, `node tools/check-stt-architecture.mjs`, and `cargo run -p build-user-guide`; run `npm run typecheck`, `npm run build`, and `npm test` in both UI directories; then require the native Whisper, both Miri, unsigned-package recovery, and signed-release workflow jobs to pass. +- Component boundary: ends Component 8 and the plan; review cumulative Steps 24 through 28 against the Step 23 commit, then run the complete exit gates from a clean tree. \ No newline at end of file diff --git a/vibe/ACTIVE b/vibe/ACTIVE new file mode 100644 index 00000000..a76ccff2 --- /dev/null +++ b/vibe/ACTIVE @@ -0,0 +1 @@ +2026-09-07-1-promptforge-debt diff --git a/vibe/agent-runtime-field-comparison-and-adoption.md b/vibe/agent-runtime-field-comparison-and-adoption.md new file mode 100644 index 00000000..122b7b06 --- /dev/null +++ b/vibe/agent-runtime-field-comparison-and-adoption.md @@ -0,0 +1,137 @@ +# Agent Runtime Architecture: Field Comparison and Adoption Priorities + +Report type: evaluation / review. It judges PromptForge against five open-source agent runtimes sharing its tool-loop and hybrid-orchestration technique, then prescribes idioms to adopt in payoff order. + +## Executive summary + +PromptForge is not inventing agent loops, but its exact combination of rendered Markdown prose and editable Lua orchestration remains unusual and defensible. Tactus comes closest to the proposed callable Lua episode model; IronCrew comes closest to the Rust and `mlua` stack; Leeway most clearly demonstrates deterministic nodes wrapped around autonomous loops; Goose supplies the mature context, elicitation, and recovery machinery; agent-runtime provides a small readable version of workflow-agent composition. PromptForge already beats all five on typed Markdown prose and direct author control, but it trails the field on automatic compaction, complete tool-history projection, resumable episode identity, and generic blocking input. The highest-payoff move is to make every model-facing section one callable agent context while keeping Lua in charge of when prose becomes a system message, user message, one model round, or a complete tool loop. + +### Key findings + +1. **Adopt Tactus and IronCrew's callable episode boundary.** Four references independently place autonomous model loops inside deterministic orchestration, while PromptForge still exposes pipeline and agent executors as siblings that cannot compose. Confidence: high. +2. **Steal Goose's dual-visibility compaction model.** Keep the complete event log, replace only the model projection, preserve system and pinned context, and compact tool exchanges atomically. Confidence: high. +3. **Centralize tool-protocol healing before every provider call.** Goose, IronCrew, Tactus, and agent-runtime all repair or reject incomplete assistant-call and tool-result groups at one boundary. Confidence: high. +4. **Promote Workshop input into a generic elicitation protocol.** The field treats human input as a durable host-owned wait usable by deterministic Lua and autonomous model tools. Confidence: high. +5. **Bind system, tools, model, and compaction policy into episode identity.** IronCrew and Tactus show how to reject silent resume under changed behavior. Confidence: high. +6. **Return autonomous decisions through typed signals.** Leeway and Goose keep model judgment inside a deterministic outer graph by validating structured episode outcomes. Confidence: high. +7. **Persist reconstructible effects rather than opaque executor stacks.** Goose, Tactus, and agent-runtime demonstrate restart from durable boundaries. Confidence: medium. +8. **Split orchestration centers before adding these mechanisms.** Every reference that deferred decomposition accumulated giant modules or parallel engines. Confidence: high. + +## Method + +The study first profiled PromptForge through nine architecture lenses and named eight deficits and five strengths. A field survey checked sixteen candidates against source and shortlisted five by technique fit, using popularity only as a tiebreaker. Five dives inspected pinned tip revisions, and four provenance examinations traced cited idioms through repository history; Tactus remained tip-only at the operator's direction. A final citation check opened every cited location in the pinned clones and verified 44 of 46 on the first pass; both failures were incomplete ranges and were corrected before this report. + +## Reference projects and provenance + +**Tactus.** 2 stars. Chosen as the closest Lua model for explicit model calls, stateful agents, tools, human interaction, and child procedures inside imperative control. MIT. Provenance unknown by operator instruction; tip source only. + +**IronCrew.** 2 stars. Chosen as the closest Rust 2024, Tokio, `mlua`, blocking-input, subflow, and tool-loop stack. MIT. Four cited idioms carry strong human signals and two carry explicit AI markers. + +**Leeway.** 113 stars. Chosen as the clearest deterministic graph around bounded interactive or autonomous agent nodes. MIT. Five cited idioms carry explicit AI markers and have no earlier form. + +**Goose.** 53,962 stars. Chosen as the mature Rust implementation of compaction, session effects, tool healing, recipes, and elicitation. Apache-2.0. Six cited idioms carry explicit AI markers; available rewinds retained or tightened the mechanisms. + +**agent-runtime.** 5 stars. Chosen as the small readable Rust implementation of agent steps, workflows, checkpoints, and complete tool history. MIT OR Apache-2.0. Six cited idioms carry strong human signals. + +## Baseline: PromptForge already owns the rare language idea + +PromptForge splits a Rust workspace into a Markdown parser, document executor, standalone Lua agent executor, shared Lua VM and coroutine protocol, model client, tool registry, store, gateway, Workshop server, and Tauri UI. Its document runtime makes prose a typed AST block and exposes explicit `reply`, `var`, store, `execute`, and `fanout`; its agent runtime exposes raw message roles, `models.chat`, event history, and blocking Workshop input. No shortlisted reference combines ordinary rendered Markdown with a sandboxed Lua controller this directly. + +The split now blocks the proposed product. Document sections cannot invoke interactive or autonomous agent programs, while agent programs lack document control operations. Automatic compaction and arbitrary pinned messages do not exist. Built-in chat reconstructs only user and final assistant messages, so tool history is incomplete. Workshop's robust input lifecycle is not a generic executor contract, and production relaunch does not yet restore durable session files. + +## Detailed findings, ranked by payoff + +### Finding 1: Make every model-facing section one callable agent context + +Tactus is the closest prior art. Its Lua procedures call stateless models, stateful tool-using agents, direct tools, human interactions, and child procedures as ordinary operations, while the execution context checkpoints side-effecting boundaries ([execution context](https://github.com/AnthusAI/Tactus/blob/08fc62ee2fcb6ccf58d0467a580551c7c7d6c121/tactus/core/execution_context.py#L228-L440), [handles](https://github.com/AnthusAI/Tactus/blob/08fc62ee2fcb6ccf58d0467a580551c7c7d6c121/tactus/primitives/handles.py#L90-L292), [tools](https://github.com/AnthusAI/Tactus/blob/08fc62ee2fcb6ccf58d0467a580551c7c7d6c121/tactus/primitives/tool.py#L77-L152), [human input](https://github.com/AnthusAI/Tactus/blob/08fc62ee2fcb6ccf58d0467a580551c7c7d6c121/tactus/primitives/human.py#L141-L213), [child procedures](https://github.com/AnthusAI/Tactus/blob/08fc62ee2fcb6ccf58d0467a580551c7c7d6c121/tactus/primitives/procedure_callable.py#L63-L222)). IronCrew independently launches a fresh Lua subflow from any runtime VM and transfers only bounded JSON ([subflow contract](https://github.com/skitsanos/ironcrew/blob/48cb8376cd9c587f85daf99266ac36f6562018c1/src/lua/subflow.rs#L14-L180)). + +Leeway and agent-runtime confirm that this is not a niche Lua idea. Leeway wraps a fresh bounded model loop in each deterministic graph node ([node schema](https://github.com/hardness1020/Leeway/blob/7601e5efe1a341374380d8a11409c4d85597cc92/src/leeway/workflow/types.py#L90-L117), [execution](https://github.com/hardness1020/Leeway/blob/7601e5efe1a341374380d8a11409c4d85597cc92/src/leeway/workflow/engine.py#L171-L328)); agent-runtime makes an agent invocation a normal workflow step ([AgentStep](https://github.com/tsharp/agent-runtime/blob/07ebca8ec36d15eae2d264d4998fa6857f9e0b51/src/workflow/steps/agent.rs#L30-L78)). + +PromptForge should replace its sibling-executor split with one section agent context. Markdown prose remains readable prompt data, while following Lua decides whether to create a system message, pin user context, perform one model round, run a complete tool loop, wait for user input, or launch a child episode. This retains PromptForge's strongest idea and adopts the field's convergent episode boundary. Confidence: high - four independent implementations converge on deterministic outer control around autonomous inner loops. + +### Finding 2: Build compaction as a projection over durable history + +Goose supplies the strongest implementation. It preserves original messages for the user and audit history while making compacted messages invisible to the agent, then appends an agent-only summary and continuation context ([context manager](https://github.com/aaif-goose/goose/blob/5e90925962f05acf8e255032de44d16c4a7768a2/crates/goose/src/context_mgmt/mod.rs#L70-L202)). Proactive compaction and typed context-error recovery preserve the current user request and active turn state ([compaction operation](https://github.com/aaif-goose/goose/blob/5e90925962f05acf8e255032de44d16c4a7768a2/crates/goose/src/agents/state_machine/ops_compaction.rs#L220-L313)). + +Leeway corroborates the policy layering. It first clears stale tool-result bodies, then summarizes older messages without tools while retaining recent messages ([query loop](https://github.com/hardness1020/Leeway/blob/7601e5efe1a341374380d8a11409c4d85597cc92/src/leeway/engine/query.py#L53-L96), [compaction](https://github.com/hardness1020/Leeway/blob/7601e5efe1a341374380d8a11409c4d85597cc92/src/leeway/services/compact/__init__.py#L21-L129), [summary stage](https://github.com/hardness1020/Leeway/blob/7601e5efe1a341374380d8a11409c4d85597cc92/src/leeway/services/compact/__init__.py#L272-L317)). IronCrew supplies the last-resort invariant: remove complete old turn groups without splitting tool protocol pairs ([atomic eviction](https://github.com/skitsanos/ironcrew/blob/48cb8376cd9c587f85daf99266ac36f6562018c1/src/llm/provider.rs#L410-L504)). + +PromptForge should preserve its event log as canonical truth and compact only the model projection. The default policy should preserve system and explicitly pinned messages, clear or summarize consumed tool bodies, summarize old turns, retain a recent verbatim tail, and finally fail or evict complete turns according to an author-selected policy. Confidence: high - Goose tests the mature form, while Leeway and IronCrew confirm the two lower layers. + +### Finding 3: Validate and heal complete tool exchanges at one boundary + +Goose repairs malformed arguments, denied calls, interruption, cancellation, and missing responses by generating correlated tool results before the next provider turn ([inference repair](https://github.com/aaif-goose/goose/blob/5e90925962f05acf8e255032de44d16c4a7768a2/crates/goose-agent/src/inference.rs#L190-L263), [tool dispatch repair](https://github.com/aaif-goose/goose/blob/5e90925962f05acf8e255032de44d16c4a7768a2/crates/goose/src/agents/state_machine/ops_toolcalling.rs#L930-L1019)). IronCrew validates one leading system message, complete assistant-call and tool-result pairing, and safe turn boundaries before dispatch and persistence ([history validator](https://github.com/skitsanos/ironcrew/blob/48cb8376cd9c587f85daf99266ac36f6562018c1/src/llm/provider.rs#L254-L504)). Tactus removes orphan tool results at the final provider boundary ([provider assembly](https://github.com/AnthusAI/Tactus/blob/08fc62ee2fcb6ccf58d0467a580551c7c7d6c121/tactus/dspy/module.py#L141-L258)). agent-runtime returns the complete call-result-final-text transcript from each episode ([agent loop](https://github.com/tsharp/agent-runtime/blob/07ebca8ec36d15eae2d264d4998fa6857f9e0b51/src/agent/mod.rs#L328-L507), [history tests](https://github.com/tsharp/agent-runtime/blob/07ebca8ec36d15eae2d264d4998fa6857f9e0b51/tests/chat_history_tests.rs#L132-L157)). + +PromptForge should build provider messages through one context projector that validates or repairs every tool exchange immediately before dispatch. Durable events remain untouched. This eliminates the built-in chat agent's current loss of call and result history without forcing Lua authors to reconstruct provider protocol. Confidence: high - complete pairing is a runtime invariant in every relevant reference. + +### Finding 4: Use one blocking-input protocol for Lua and model tools + +Goose keys elicitation by session and tool-call identity, persists the human response before resolving the blocked future, rejects wrong-session and duplicate answers, and removes pending state on every completion path ([action manager](https://github.com/aaif-goose/goose/blob/5e90925962f05acf8e255032de44d16c4a7768a2/crates/goose/src/action_required_manager.rs#L49-L191), [persistence ordering](https://github.com/aaif-goose/goose/blob/5e90925962f05acf8e255032de44d16c4a7768a2/crates/goose/src/elicitation.rs#L24-L70)). IronCrew uses one run-scoped bridge for scripted Lua and model-visible human tools, and pauses task timeout accounting while a question is pending ([input bridge](https://github.com/skitsanos/ironcrew/blob/48cb8376cd9c587f85daf99266ac36f6562018c1/src/engine/input_bridge.rs#L1-L12), [human tool](https://github.com/skitsanos/ironcrew/blob/48cb8376cd9c587f85daf99266ac36f6562018c1/src/tools/ask_human.rs#L90-L199), [timeout accounting](https://github.com/skitsanos/ironcrew/blob/48cb8376cd9c587f85daf99266ac36f6562018c1/src/engine/task_runner.rs#L30-L69)). + +Tactus persists replayable requests and races multiple attended or asynchronous channels under one interaction identity ([human primitives](https://github.com/AnthusAI/Tactus/blob/08fc62ee2fcb6ccf58d0467a580551c7c7d6c121/tactus/primitives/human.py#L141-L1008), [channel broker](https://github.com/AnthusAI/Tactus/blob/08fc62ee2fcb6ccf58d0467a580551c7c7d6c121/tactus/adapters/control_loop.py#L211-L435)). Leeway serializes questions from concurrent branches through one lock ([HITL broker](https://github.com/hardness1020/Leeway/blob/7601e5efe1a341374380d8a11409c4d85597cc92/src/leeway/workflow/hitl.py#L10-L41)). + +PromptForge should retain Workshop's stronger tokenized wait and reconnect lifecycle, but move it into the common executor. Lua and optional model tools should call the same `user_input` primitive, while the launch host chooses blocking, immediate fallback, or failure. Confidence: high - host-owned durable elicitation is a clear field consensus. + +### Finding 5: Bind effective context into episode identity + +IronCrew fingerprints every non-secret input controlling a durable conversation: source, selected agent, effective system prompt, model, context limits, tool rounds, resolved tool graph, and provider behavior ([identity](https://github.com/skitsanos/ironcrew/blob/48cb8376cd9c587f85daf99266ac36f6562018c1/src/engine/conversation_identity.rs#L10-L84), [definition](https://github.com/skitsanos/ironcrew/blob/48cb8376cd9c587f85daf99266ac36f6562018c1/src/engine/conversation_definition.rs#L19-L84)). Tactus builds the exact provider payload, counts it with the selected model, hashes and authorizes it, then dispatches that same object without a second reconstruction ([payload build](https://github.com/AnthusAI/Tactus/blob/08fc62ee2fcb6ccf58d0467a580551c7c7d6c121/tactus/dspy/module.py#L105-L258), [attempt authority](https://github.com/AnthusAI/Tactus/blob/08fc62ee2fcb6ccf58d0467a580551c7c7d6c121/tactus/dspy/agent.py#L1897-L2074)). Goose rebuilds persistent and ephemeral system contributions in stable keyed order before inference ([prompt manager](https://github.com/aaif-goose/goose/blob/5e90925962f05acf8e255032de44d16c4a7768a2/crates/goose/src/agents/prompt_manager.rs#L19-L242)). + +PromptForge should seal the effective system prompt, ordered tool schemas, model options, compaction policy, and projection rules at the first model call. Resume under changed values should create a new incarnation or fail explicitly. Confidence: high - the references make context identity testable and prevent old history from silently running under new authority. + +### Finding 6: Return agent judgment through typed Lua-visible signals + +Leeway derives the legal model decisions from outgoing graph edges, advertises them through a dedicated tool, rejects out-of-scope decisions, and evaluates the result deterministically ([legal signals](https://github.com/hardness1020/Leeway/blob/7601e5efe1a341374380d8a11409c4d85597cc92/src/leeway/workflow/types.py#L185-L194), [signal tool](https://github.com/hardness1020/Leeway/blob/7601e5efe1a341374380d8a11409c4d85597cc92/src/leeway/workflow/signal_tool.py#L10-L69), [evaluator](https://github.com/hardness1020/Leeway/blob/7601e5efe1a341374380d8a11409c4d85597cc92/src/leeway/workflow/evaluator.py#L20-L33)). Goose pairs typed recipe outputs with deterministic post-run checks and bounded retries ([recipe contract](https://github.com/aaif-goose/goose/blob/5e90925962f05acf8e255032de44d16c4a7768a2/crates/goose/src/recipe/mod.rs#L40-L128), [retry operation](https://github.com/aaif-goose/goose/blob/5e90925962f05acf8e255032de44d16c4a7768a2/crates/goose/src/agents/state_machine/ops_retry.rs#L229-L283)). + +PromptForge should let `tool_loop()` return more than text: final text, a validated decision, structured data, usage, and completion status. Lua then owns the next edge without scraping control intent from prose. Confidence: high - typed outcome tools preserve model judgment while keeping orchestration deterministic. + +### Finding 7: Persist reconstructible effects instead of executor stacks + +Goose reloads durable session state before each operation, applies one operation effect, persists it, and can reconstruct the pipeline after every step ([machine](https://github.com/aaif-goose/goose/blob/5e90925962f05acf8e255032de44d16c4a7768a2/crates/goose-agent/src/machine.rs#L48-L171), [session effects](https://github.com/aaif-goose/goose/blob/5e90925962f05acf8e255032de44d16c4a7768a2/crates/goose/src/agents/state_machine/session.rs#L40-L149)). Tactus checkpoints each side-effecting Lua boundary and stores children or continuations as host-owned references ([checkpoint loop](https://github.com/AnthusAI/Tactus/blob/08fc62ee2fcb6ccf58d0467a580551c7c7d6c121/tactus/core/execution_context.py#L228-L564)). agent-runtime serializes a context checkpoint, restores it, and continues a later workflow ([context](https://github.com/tsharp/agent-runtime/blob/07ebca8ec36d15eae2d264d4998fa6857f9e0b51/src/context/mod.rs#L11-L95), [checkpoint tests](https://github.com/tsharp/agent-runtime/blob/07ebca8ec36d15eae2d264d4998fa6857f9e0b51/tests/checkpoint_tests.rs#L5-L170)). + +PromptForge should add a versioned execution snapshot anchored to its append-only event offset. The snapshot should identify the section incarnation, replay position, active model projection, and outstanding input or child waits. Lua should replay from stable boundaries rather than serialize a coroutine stack. Confidence: medium - the effect-replay pattern is proven, but mapping existing block coroutines onto stable checkpoints needs design work. + +### Finding 8: Split orchestration centers before adding context policy + +Goose proves that inference, compaction, tools, recipes, and session effects can sit behind small operation contracts ([operation interface](https://github.com/aaif-goose/goose/blob/5e90925962f05acf8e255032de44d16c4a7768a2/crates/goose-agent/src/operation.rs#L72-L153)), but its product still carries parallel legacy and state-machine loops plus multi-thousand-line orchestration files. IronCrew enforces a size ratchet while carrying forty-eight explicit exceptions, including persistence modules above three thousand lines ([ratchet](https://github.com/skitsanos/ironcrew/blob/48cb8376cd9c587f85daf99266ac36f6562018c1/scripts/check_module_size.py#L1-L138), [exceptions](https://github.com/skitsanos/ironcrew/blob/48cb8376cd9c587f85daf99266ac36f6562018c1/scripts/module_size_policy.json#L21-L189)). Tactus concentrates runtime, agent, DSL, and IDE policy in modules between roughly 2,600 and 4,200 lines. + +PromptForge's scheduler, Lua protocol, agent driver, and Workshop session registry already exceed one thousand lines. Context projection, compaction, episode identity, and interaction brokering should become separate modules before the executors merge. Confidence: high - every reference that left these concerns in central loops accumulated duplicate paths, unenforced declarations, or severe review surfaces. + +## Provenance + +Tactus was examined only at its pinned tip, so its cited idioms carry unknown provenance by operator instruction. IronCrew's subflow composition, durable identity, agent-as-tool, and system-tool identity mechanisms carry strong human signals; its history validator and part of timeout accounting carry explicit AI markers with no earlier form. All cited Leeway mechanisms were introduced in explicitly AI-marked commits and have no pre-AI form. Every Goose finding touches at least one explicitly AI-marked file, but available rewinds show that dual-visibility compaction, keyed system context, elicitation, delegation, and deterministic retry already existed and were retained or tightened. All cited agent-runtime idioms carry strong human signals. + +This sample supports no broad claim about AI-authored code. The provenance tags price individual mechanisms only. In particular, Leeway's tip contains major ownership and lifecycle defects despite its coherent generated architecture, while Goose's rewinds show mature mechanisms surviving later AI-assisted modification. + +## Where the subject already matches or beats the references + +PromptForge's typed Markdown AST makes prose a clearer runtime object than Tactus task strings, Leeway YAML prompts, Goose recipe prose, or agent-runtime configuration. Its Lua request and answer protocol gives authors direct control over model rounds, tool calls, section execution, and fanout that none of the non-Lua references match. Workshop's existing input wait already matches or beats the field on single-use tokens, cancellation, reconnect replay, and byte-exact event recording. PromptForge also starts with the correct compaction foundation: durable events, ephemeral stream deltas, and section-local execution state are already distinct. + +## Messes we should explicitly not copy + +Tactus declares per-turn tools and turn ceilings that its concrete execution path does not enforce; persistence-shaped APIs are placeholders, local cancellation is cosmetic, and unattended escalation fails open. IronCrew's safe whole-turn eviction is not semantic compaction, and its size ratchet normalizes forty-eight exception files instead of repairing them. Leeway's compactor replaces a local list while the owning conversation retains stale history, callback wiring differs by entry path, and its model loop and compaction lack direct tests. Goose carries old and new agent loops in parallel, and some recipe sequencing remains prose policy rather than runtime behavior. agent-runtime advertises context pruning and OpenAI agents that are not fully wired, duplicates error families, and uses an unsafe downcast in subworkflow execution. + +## Recommended execution order + +1. Define one section-owned context contract with explicit system, user, assistant, tool, retention, and episode-result types. This establishes Findings 1, 3, and 5 before behavior moves. +2. Turn prose into substituted data for the following Lua block, expose explicit model-round and tool-loop calls, and route both existing executors through the section context. This completes Finding 1 without adding implicit terminal-prose behavior. +3. Add the provider-boundary context projector and complete tool-protocol healing from Finding 3. +4. Add exact request token accounting, system and pinned retention, recent-tail policy, semantic summary, and hard-fail compaction strategies from Finding 2. +5. Lift `user_input` into the generic wait protocol and expose the same broker to Lua and optional model tools, satisfying Finding 4. +6. Bind sealed context into incarnation identity and implement replayable execution snapshots from Findings 5 and 7. +7. Add typed episode signals and deterministic postconditions from Finding 6. +8. Keep the new projection, compaction, identity, and input broker modules outside the existing central files, then ratchet those files downward as required by Finding 8. + +## Refactor notes + +Findings 3 and 8 are primarily structural; Findings 1, 2, 4, 5, 6, and 7 change runtime behavior and public contracts. Existing parser, pipeline, agent, tool-loop, Workshop-session, and event-log tests are the invariant and move only after replacement coverage passes. Keep gateway provider conversion, store behavior, and Workshop presentation outside the first merger. Verify each step with focused crate tests and the workspace suite at component boundaries. Commit each verified slice separately. Stop and re-plan after two consecutive failures with the same signature on one step. + +## Sources + +- Tactus: https://github.com/AnthusAI/Tactus at `08fc62ee2fcb6ccf58d0467a580551c7c7d6c121`, MIT, analyzed 2026-09-06. Provenance intentionally not examined. +- IronCrew: https://github.com/skitsanos/ironcrew at `48cb8376cd9c587f85daf99266ac36f6562018c1`, MIT, analyzed 2026-09-06. Cited AI-originated files had no earlier form. +- Leeway: https://github.com/hardness1020/Leeway at `7601e5efe1a341374380d8a11409c4d85597cc92`, MIT, analyzed 2026-09-06. Cited files had no pre-AI form. +- Goose: https://github.com/aaif-goose/goose at `5e90925962f05acf8e255032de44d16c4a7768a2`, Apache-2.0, analyzed 2026-09-06. Rewinds: `5b93ee587feb4135146b27ad8683a9a9b6bd2feb`, `09c8d2be5aba1b6aa91794c21574cdd770c33ad9`, `6782d1f5062e4bc3a8371808bbd99ee05fa19b16`, `72da97204e123be70efb8d46f8217155bf83f404`, `4aa5de150a86a2c02d0fc45aec9819cad9cec5c2`, `838d99e0499433824016e93342563515230cb0f3`, and `fb47728f1b39a73bdc701b7e5890c39f11df2260`. +- agent-runtime: https://github.com/tsharp/agent-runtime at `07ebca8ec36d15eae2d264d4998fa6857f9e0b51`, MIT OR Apache-2.0, analyzed 2026-09-06. +- Field survey: sixteen candidates verified or classified on 2026-09-06; popularity values recorded that day. +- PromptForge subject profile: source tree profiled on 2026-09-06. + +*2026-09-06 08:55 - GPT-5.6 Sol* diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index ad6953d8..1932d6de 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -126,3 +126,90 @@ - [2026-09-04-2-apply-as-queue-command] ambient loopback trust: Keyless admin access needs verified loopback peer identity plus browser fetch-metadata checks; explicit bad credentials still fail closed. - [2026-09-04-3-unlock-inference-during-switches] transitional-state cleanup: A failed or cancelled spawn clears loading markers, tears down partial children, and leaves the surviving routing usable. - [2026-09-04-3-unlock-inference-during-switches] bounded operational waits: Worker joins and idle artifact reads need finite bounds so cancellation and shutdown cannot hang indefinitely. +N1 | observation | Violates A2 @ crates/gateway-stt/tests/fixtures/realtime: not determinable from diff | Freeze the realtime transcription wire contract +N2 | observation | Violates A96 @ crates/workshop-server/ui/test/realtime-wire-fixtures.mjs: not determinable from diff | Freeze the realtime transcription wire contract; Record installed acceptance and repair UI fixture +N3 | observation | shared-parameter-cluster @ crates/gateway-stt-engine/src/engine.rs::SttEngine::transcribe_final: repeats samples, guidance, and finalized history across decode signatures | Move take ownership into gateway STT; Harden STT workers and extend release gates +N4 | observation | shared-parameter-cluster @ crates/gateway-stt-engine/src/final_pass.rs::FinalTranscriber::transcribe: repeats samples, guidance, and finalized history across decode signatures | Move take ownership into gateway STT +N5 | observation | shared-mutable-state @ crates/gateway-stt/src/take.rs::TakeState: shares mutex-protected take state between the socket and final pipeline tasks | Move take ownership into gateway STT; Bound Realtime session input ownership; Finalize realtime items independently; Replace the STT runtime with a speech facade; Publish generic speech discovery facts; Mount Gateway Realtime transcription +N6 | observation | oversized-unit @ crates/gateway-stt/src/take.rs: adds a 651-line take module | Move take ownership into gateway STT; Add bounded realtime audio ingestion; Bound Realtime session input ownership; Finalize realtime items independently; Replace the STT runtime with a speech facade; Publish generic speech discovery facts; Mount Gateway Realtime transcription; Make session retirement cleanup event-driven; Partition live hypotheses into disjoint fields; Schedule and rebase whole-window hypotheses +N7 | observation | oversized-unit @ crates/gateway-stt/tests/common/mod.rs: adds bounded shutdown logic to an already oversized test support module | Move take ownership into gateway STT +N8 | observation | oversized-unit @ crates/gateway-stt/tests/it/legacy_stream.rs: adds explicit shutdown calls to an already oversized integration suite | Move take ownership into gateway STT; Reconcile explicitly skipped final ranges +N9 | observation | Violates A2 @ crates/gateway-stt/src/runtime.rs: not determinable from diff | Move take ownership into gateway STT; Separate Whisper from the STT engine; Harden STT workers and extend release gates; Migrate STT tuning to canonical configuration; Add bounded realtime audio ingestion +N10 | observation | Violates A96 @ crates/gateway-stt/src/api.rs: not determinable from diff | Move take ownership into gateway STT; Harden STT workers and extend release gates +N11 | observation | flag-parameter @ crates/gateway-stt-backend-whisper/src/model.rs::WhisperDecoder::load: selects interim or final decode policy through final_pass | Separate Whisper from the STT engine +N12 | observation | flag-parameter @ crates/gateway-stt-engine/src/worker.rs::worker_loop: selects interim or final factory construction through final_model | Separate Whisper from the STT engine; Bound transcription workers and expose test fixtures; Harden STT workers and extend release gates +N13 | observation | global-state @ crates/gateway-stt-backend-whisper/src/prompt.rs::NATIVE_TEST: serializes fixture-dependent prompt tests with a process-wide mutex | Separate Whisper from the STT engine +N14 | observation | global-state @ crates/gateway-stt-backend-whisper/tests/native_whisper.rs::NATIVE_TEST: serializes native backend tests with a process-wide mutex | Separate Whisper from the STT engine +N15 | observation | clone-block @ crates/gateway-stt/src/test_fixtures.rs: duplicates native fixture loading across unit and integration test support | Separate Whisper from the STT engine; Quiesce speech generations before replacement; Centralize native STT fixture resolution +N16 | observation | clone-block @ crates/gateway-stt/tests/common/mod.rs: duplicates native fixture loading across integration and unit test support | Separate Whisper from the STT engine; Centralize native STT fixture resolution +N17 | observation | Violates A2 @ crates/gateway-stt/tests/it/architecture.rs: not determinable from diff | Enforce exact STT architecture ratchets; Finalize generic Realtime STT architecture; Centralize native STT fixture resolution; Ratchet feature-enabled STT fixture APIs +N18 | observation | feature-flag @ crates/gateway-stt-backend-whisper/Cargo.toml::test-fixtures: forwards scripted engine fixtures without an expiry | Bound transcription workers and expose test fixtures +N19 | observation | feature-flag @ crates/gateway-stt-engine/Cargo.toml::test-fixtures: gates downstream scripted decoder fixtures without an expiry | Bound transcription workers and expose test fixtures; Centralize native STT fixture resolution +N20 | observation | surface-growth @ crates/gateway-stt-engine/src/lib.rs::test_fixtures: exposes scripted decoder controls to downstream consumers | Bound transcription workers and expose test fixtures; Partition live hypotheses into disjoint fields; Centralize native STT fixture resolution +N21 | observation | shared-mutable-state @ crates/gateway-stt-engine/src/test_fixtures.rs::ScriptedDecoder: shares mutex-protected decoder controls across worker and test owners | Bound transcription workers and expose test fixtures; Harden STT workers and extend release gates; Make profile replacement transactional; Partition live hypotheses into disjoint fields; Narrow STT fixture controls to scenarios +N22 | observation | temporal-coupling @ crates/gateway-stt-engine/src/test_fixtures.rs::ScriptedDecoder: requires park, wait, and release calls in sequence | Bound transcription workers and expose test fixtures; Harden STT workers and extend release gates; Make profile replacement transactional; Partition live hypotheses into disjoint fields; Narrow STT fixture controls to scenarios +N23 | observation | feature-flag @ crates/gateway-stt/Cargo.toml::test-fixtures: gates the downstream speech test facade without an expiry | Bound transcription workers and expose test fixtures +N24 | observation | surface-growth @ crates/gateway-stt/src/lib.rs::test_fixtures: exposes scripted speech runtime construction to downstream consumers | Bound transcription workers and expose test fixtures; Bound Realtime session input ownership; Finalize realtime items independently; Replace the STT runtime with a speech facade; Quiesce speech generations before replacement; Publish generic speech discovery facts; Mount Gateway Realtime transcription; Make session retirement cleanup event-driven +N25 | observation | facade @ crates/gateway-stt/src/lib.rs::test_fixtures: combines engine fixtures with speech runtime construction | Bound transcription workers and expose test fixtures; Replace the STT runtime with a speech facade; Quiesce speech generations before replacement; Publish generic speech discovery facts; Mount Gateway Realtime transcription +N26 | observation | constructor-injection @ crates/gateway-stt/src/runtime.rs::SttRuntime::from_scripted_engine: receives the scripted engine and runtime settings as parameters | Bound transcription workers and expose test fixtures; Bound Realtime session input ownership; Replace the STT runtime with a speech facade +N27 | observation | Violates A115 @ crates/gateway-stt/src/runtime.rs: control readiness during model startup is not determinable from diff | Harden STT workers and extend release gates; Migrate STT tuning to canonical configuration +N28 | observation | Violates A116 @ crates/gateway/src/config_apply.rs::stt_pipeline_change_reloads_without_restart: publication consistency is not determinable from diff | Migrate STT tuning to canonical configuration +N29 | observation | Violates A96 @ crates/gateway-config-ui/ui/src/services/config-store.ts::canonicalizeStt: bounded third-party model content is not determinable from diff | Migrate STT tuning to canonical configuration +N30 | observation | Violates A2 @ crates/gateway-stt/src/realtime: not determinable from diff | Define the private Realtime wire; Bound Realtime session input ownership; Finalize realtime items independently; Mount Gateway Realtime transcription; Make session retirement cleanup event-driven; Partition live hypotheses into disjoint fields; Schedule and rebase whole-window hypotheses; Restore dead-code diagnostics for gateway STT +N31 | observation | global-state @ crates/gateway-stt/src/realtime/wire/shared.rs::NEXT_GENERATOR: allocates ID generator namespaces from a process-wide atomic counter | Define the private Realtime wire +N32 | observation | oversized-unit @ crates/gateway-stt/src/realtime/wire/server.rs::ServerEvent::validate: adds an 85-line server event validator | Define the private Realtime wire +N33 | observation | flag-parameter @ crates/gateway-stt/src/realtime/wire/client.rs::parse_empty: selects commit or clear event construction through commit | Define the private Realtime wire +N34 | observation | clone-block @ crates/gateway-stt/tests/it/realtime_session.rs: repeats session update, audio encoding, snapshot, clear, epoch, and capacity cases from unit tests | Bound Realtime session input ownership; Finalize realtime items independently; Make session retirement cleanup event-driven +N35 | observation | hidden-dependency @ crates/gateway-stt/src/generation.rs::unload: waits for generation and engine reference counts outside its interface | Replace the STT runtime with a speech facade; Quiesce speech generations before replacement +N36 | observation | Violates A2 @ crates/gateway-stt/src/service.rs::SpeechService: credential ownership is not determinable from diff | Replace the STT runtime with a speech facade; Make profile replacement transactional; Publish generic speech discovery facts; Retire legacy speech seams +N37 | observation | Violates A115 @ crates/gateway/src/runner.rs::Gateway::from_config_with_hub: control readiness during speech provisioning is not determinable from diff | Replace the STT runtime with a speech facade; Make profile replacement transactional +N38 | observation | shared-parameter-cluster @ crates/gateway-stt/src/generation/snapshot.rs::Generation::from_factory: repeats id, backend, names, and guidance across generation constructors | Quiesce speech generations before replacement; Make profile replacement transactional +N39 | observation | global-state @ crates/gateway/src/config_write.rs::PERSISTENCE_SEQUENCE: allocates persistence temporary suffixes from a process-wide atomic counter | Make profile replacement transactional; Harden prepared persistence names; Extract profile switch preparation phases +N40 | observation | hidden-dependency @ crates/gateway/src/config_write.rs::persistence_temporary: reads process identity and a global sequence outside its interface | Make profile replacement transactional; Harden prepared persistence names +N41 | observation | shared-parameter-cluster @ crates/gateway/src/lib.rs::prepare_cutover: repeats state, profile, and cancellation across switch phase functions | Make profile replacement transactional; Extract profile switch preparation phases +N42 | observation | shared-parameter-cluster @ crates/gateway/src/lib.rs::run_switch_phases: repeats state, profile, and cancellation across switch phase functions | Make profile replacement transactional; Extract profile switch preparation phases +N43 | observation | shared-parameter-cluster @ crates/gateway/src/lib.rs::commit_switch: repeats state, profile, and cancellation across switch phase functions | Make profile replacement transactional; Complete the profile-switch transaction +N44 | observation | shared-parameter-cluster @ crates/gateway/src/lib.rs::restore_or_shutdown: repeats state, cancellation, and failure across rollback functions | Make profile replacement transactional; Extract profile switch preparation phases +N45 | observation | shared-parameter-cluster @ crates/gateway/src/lib.rs::request_fatal_shutdown: repeats state, cancellation, and failure across rollback functions | Make profile replacement transactional; Complete the profile-switch transaction +N46 | observation | shared-parameter-cluster @ crates/gateway/src/lib.rs::rollback_commit_failure: repeats state, cancellation, and failure across rollback functions | Make profile replacement transactional; Complete the profile-switch transaction +N47 | observation | flag-parameter @ crates/workshop-server/src/gateway/socket.rs::GatewayClient::connect_socket: selects the legacy status header through workshop_status | Add the Workshop Realtime relay; Retire legacy speech seams +N48 | observation | shared-mutable-state @ crates/workshop-server/tests/it/realtime_relay.rs::UpstreamProbe: shares mutex-protected request and frame observations across relay and test owners | Add the Workshop Realtime relay +N49 | observation | oversized-unit @ crates/workshop-server/tests/it/realtime_relay.rs::upstream: adds a 79-line upstream probe handler | Add the Workshop Realtime relay; Require a model before built-in chat turns; Converge chat sessions with live catalogs; Split Workshop relay integration coverage +N50 | observation | oversized-unit @ crates/workshop-server/tests/it/realtime_relay.rs::realtime_relay_is_authenticated_fixed_and_payload_opaque: adds an 83-line wire behavior test | Add the Workshop Realtime relay; Require a model before built-in chat turns; Converge chat sessions with live catalogs; Split Workshop relay integration coverage +N51 | observation | shared-mutable-state @ crates/workshop-server/tests/it/realtime_relay.rs::StalledPeerProbe: shares frame delivery state between peer and test owners | Add the Workshop Realtime relay +N52 | observation | shared-mutable-state @ crates/workshop-server/ui/src/main.ts::speechCapture: shares one mutable microphone capture service across agent panels | Migrate Workshop dictation to Realtime +N53 | observation | surface-growth @ crates/workshop-server/ui/src/services/realtime-transcription.ts: exports the browser Realtime socket, event, and options contract | Migrate Workshop dictation to Realtime; Converge Workshop startup state; Decode Realtime events exhaustively +N54 | observation | event-hook @ crates/workshop-server/ui/src/services/realtime-transcription.ts::RealtimeTranscriptionService: exposes transcription state and item outcomes through six callback events | Migrate Workshop dictation to Realtime; Converge Workshop startup state +N55 | observation | dispatch-on-tag @ crates/workshop-server/ui/src/services/realtime-transcription.ts::handleMessage: routes server events through one string-tag branch chain | Migrate Workshop dictation to Realtime; Converge Workshop startup state; Decode Realtime events exhaustively +N56 | observation | shared-parameter-cluster @ crates/workshop-server/ui/src/ui/realtime-stt.ts::setupStt: repeats elements, status, and blocker across Realtime and legacy setup signatures | Migrate Workshop dictation to Realtime +N57 | observation | surface-growth @ crates/workshop-server/ui/src/ui/stt.ts::SttInputTarget: requires consumers to expose selected-range text for rollback | Migrate Workshop dictation to Realtime; Require a model before built-in chat turns; Recover Workshop after local Gateway exits +N58 | observation | constructor-injection @ crates/workshop-server/ui/src/ui/agent-session-view.ts::AgentSessionView: receives shared microphone capture through the panel constructor chain | Migrate Workshop dictation to Realtime; Require a model before built-in chat turns +N59 | observation | Violates A96 @ crates/workshop-server/ui/src/ui/realtime-stt.ts::setupStt: bounded third-party model content is not determinable from diff | Bind live hypotheses before commit acknowledgment; Move insertion context into STT targets; Route production STT through TakeRegistry +N60 | observation | Violates A2 @ crates/gateway-stt/src/take: credential ownership is not determinable from diff | Reconcile explicitly skipped final ranges +N61 | observation | oversized-unit @ crates/gateway-logging/src/queue.rs::byte_blocked_producers_wake_after_drain_and_close: adds a 98-line byte-blocked producer concurrency test | Order and bound logging queue admission; Bound logging stalls and shutdown +N62 | observation | Violates A2 @ crates/gateway-logging/src/queue.rs: credential ownership in gateway logging is not determinable from diff | Order and bound logging queue admission; Bound logging stalls and shutdown; Redact logging fields before formatting +N63 | observation | newtype @ crates/gateway-logging/src/worker.rs::LogWorker: owns the worker join handle for bounded completion or detachment | Bound logging stalls and shutdown +N64 | observation | oversized-unit @ crates/gateway-logging/src/queue.rs::LogQueue::enqueue_after: expands producer admission and timeout handling to 83 lines | Bound logging stalls and shutdown +N65 | observation | oversized-unit @ crates/gateway-logging/src/runtime.rs::assert_stalled_shutdown: adds a 97-line deterministic stalled-shutdown test helper | Bound logging stalls and shutdown +N66 | observation | shared-parameter-cluster @ crates/gateway-logging/src/queue.rs::LogQueue::new_for_test_with_wait: repeats max_records, max_bytes, and producer_wait across queue constructors | Bound logging stalls and shutdown +N67 | observation | flag-parameter @ crates/gateway-logging/src/queue.rs::LogQueue::complete_batch: uses had_summary to select summary completion accounting | Bound logging stalls and shutdown +N68 | observation | Violates A2 @ crates/gateway-logging/src/worker.rs: credential ownership in gateway logging is not determinable from diff | Rotate logs within fixed byte budgets; Redact logging fields before formatting +N69 | observation | oversized-unit @ crates/gateway-stt-engine/tests/feature_boundary.rs: adds a 179-line feature boundary integration test | Centralize native STT fixture resolution +N70 | observation | Violates A2 @ crates/gateway-stt-engine/src/test_fixtures: credential ownership in Gateway speech fixture changes is not determinable from diff | Narrow STT fixture controls to scenarios +N71 | observation | oversized-unit @ tools/check-stt-architecture.mjs::maskRustCommentsAndLiterals: adds a 97-line comment and literal masking function | Restore dead-code diagnostics for gateway STT +N72 | observation | Violates A116 @ crates/gateway/src/config_write.rs::PreparedFile: publication consistency with live state is not determinable from diff | Harden prepared persistence names +N73 | observation | Violates A117 @ crates/gateway/src/config_write.rs::PreparedFile: routing availability during switch preparation is not determinable from diff | Harden prepared persistence names +N74 | observation | Violates A2 @ crates/gateway/src/profile_switch.rs: credential ownership in the profile-switch transaction is not determinable from diff | Complete the profile-switch transaction +N75 | observation | oversized-unit @ crates/workshop-server/ui/src/services/realtime-event-decoder.ts::decodeRealtimeEvent: adds a 144-line exhaustive event decoder | Decode Realtime events exhaustively +N76 | observation | Violates A96 @ crates/workshop-server/ui/src/services/realtime-event-decoder.ts: bounded third-party model content is not determinable from diff | Decode Realtime events exhaustively +N77 | observation | oversized-unit @ crates/workshop-server/src/session_agents/supervisor/transition.rs: adds a 424-line pure supervisor transition module | Define pure agent supervisor transitions; Route agent supervision through transitions +N78 | observation | oversized-unit @ crates/workshop-server/src/session_agents/supervisor/transition/tests.rs: adds a 336-line transition table suite | Define pure agent supervisor transitions +N79 | observation | Violates A99 @ crates/workshop-server/src/session_agents/supervisor/transition.rs::SupervisorEffect: descendant cancellation propagation and sibling isolation are not determinable from diff | Define pure agent supervisor transitions; Route agent supervision through transitions +N80 | observation | shared-parameter-cluster @ crates/shared-sidecar/src/health.rs::probe_connection: repeats address, bearer path, and bearer across connection proof signatures | Add validated sidecar connection capability +N81 | observation | shared-parameter-cluster @ crates/shared-sidecar/src/health.rs::probe_connection_once: repeats address, bearer path, and bearer across connection proof signatures | Add validated sidecar connection capability +N82 | observation | flag-parameter @ crates/shared-sidecar/src/health.rs::write_request: selects keep-alive or close behavior through close | Add validated sidecar connection capability +N83 | observation | oversized-unit @ crates/shared-sidecar/src/validated.rs: adds a 492-line validated connection module | Add validated sidecar connection capability +N84 | observation | clone-block @ crates/shared-sidecar/src/validated.rs::fixture_gateway: repeats the two-request Gateway fixture server from stale-resolution tests | Add validated sidecar connection capability +N85 | observation | clone-block @ crates/shared-sidecar/src/stale.rs::fixture_gateway: repeats the two-request Gateway fixture server in capability tests | Add validated sidecar connection capability +N86 | observation | clone-block @ crates/shared-sidecar/src/lock.rs::fixture_gateway: repeats the two-response socket loop from stale-resolution tests | Add validated sidecar connection capability +N87 | observation | clone-block @ crates/shared-sidecar/src/stale.rs::a_transiently_silent_health_endpoint_is_not_stale: repeats the two-response socket loop from launch-lock tests | Add validated sidecar connection capability diff --git a/vibe/stt-field-comparison-and-adoption.md b/vibe/stt-field-comparison-and-adoption.md new file mode 100644 index 00000000..1c87f3da --- /dev/null +++ b/vibe/stt-field-comparison-and-adoption.md @@ -0,0 +1,126 @@ +# Realtime STT Architecture: Field Comparison and Adoption Priorities + +Report type: evaluation / review. It judges PromptForge against five open-source codebases sharing realtime speech-to-text architecture and prescribes idioms to adopt, in payoff order. + +## Executive summary + +PromptForge has a strong native core wrapped in an unsafe service boundary. Its FFI ownership, dedicated inference workers, and generation-aware transcript protocol beat much of the field, but the absence of a backend seam, bounded admission, and per-session final-pass ownership makes the current design brittle under extension and concurrency. The highest-payoff change is to preserve the two-model stable-plus-unstable algorithm while moving model execution behind a provider-neutral contract. + +### Key findings + +1. **Steal Vox and Dalston's backend boundary.** Separate the realtime pipeline from physical model runtimes, with whisper.cpp as the first adapter. Confidence: high. +2. **Steal bounded pressure control from all five references.** Every audio, inference, and session queue needs a declared limit and a typed overload outcome. Confidence: high. +3. **Steal owned sessions from Vox, GigaSTT, and Dalston.** Per-session take handles prevent concurrent clients from resetting each other's final-pass state. Confidence: high. +4. **Steal GigaSTT's atomic engine publication.** Requests must observe one complete model generation, not independently updated runtime fields. Confidence: medium. +5. **Steal Dalston's native protocol plus edge translators.** Gateway events should describe transcription facts, while Workshop derives UI status. Confidence: high. +6. **Steal GigaSTT's bounded shutdown order.** Stop ingress, close queues, drain under a deadline, emit one terminal outcome, then release native state. Confidence: high. +7. **Steal provider-neutral CI tests from Universal Realtime STT.** A deterministic fake backend should exercise all critical behavior without model fixtures. Confidence: high. + +## Method + +PromptForge was profiled first through nine architecture lenses, with speech-to-text weighted above unrelated code. Fifteen open-source candidates were surveyed and fourteen were verified against source; five complementary references were selected and examined at pinned commits. Each cited idiom received a provenance tag, including pre-AI rewinds where explicit markers appeared. Findings were ranked by deficit severity, convergence, and adoption cost, then 27 citations were checked against the pinned clones; nine path corrections were applied and no finding was dropped. + +## Reference projects and provenance + +| Reference | Popularity | Why chosen | License | Provenance of cited idioms | +|---|---:|---|---|---| +| [GigaSTT](https://github.com/ekhodzitsky/gigastt) | 49 stars | Closest complete Rust streaming server | MIT | 5 strong human signal | +| [Vox](https://github.com/mrtozner/vox) | 43 stars | Backend and per-session streaming abstractions | MIT OR Apache-2.0 | 6 strong human signal | +| [Keyless](https://github.com/hate/keyless) | 25 stars | Bounded queues and single-owner inference | MIT | 5 strong human signal | +| [Universal Realtime STT](https://github.com/Chronica-Anima/universal-realtime-stt) | 2 stars | Small provider lifecycle contract | MIT | 3 strong human signal, 3 unknown | +| [Dalston](https://github.com/ssarunic/dalston) | 2 stars | Native protocol, lag policy, and session lifecycle | Apache-2.0 | 1 strong human signal, 5 explicit AI marker | + +## Baseline: where the subject stands + +PromptForge is a Rust-first STT stack built from Axum, Tokio, WebSockets, dedicated whisper.cpp worker threads, and a runtime-loaded C ABI. Its strongest mechanisms are dedicated blocking workers in `gateway-transcribe/src/worker.rs` and `final_pass.rs`, RAII wrappers in `gateway-whisper-ffi/src/library.rs` and `context.rs`, generation-aware frames in `gateway-stt/src/stt.rs`, and layered errors across the FFI, transcription, and Gateway crates. + +The main deficits are concrete. `gateway-transcribe/src/engine.rs`, `worker.rs`, and `final_pass.rs` hard-code whisper.cpp rather than a backend contract. `gateway-stt/src/stt.rs` and both worker modules use unbounded buffers or queues. `gateway-stt/src/runtime.rs` publishes engine and model-name state separately, waits without a shutdown bound, and silently resets active takes during profile switches. The final-pass worker owns one global current take, so simultaneous realtime clients can interleave resets and segment notifications. Rust and TypeScript duplicate the wire schema, the browser permits a new take while finalization is pending, and model-dependent integration tests are skipped in normal CI. + +## Detailed findings, ranked by payoff + +### Finding 1: Separate the realtime pipeline from model backends + +Vox splits batch STT from per-session streaming through backend-neutral traits ([`src/traits.rs:38-114`](https://github.com/mrtozner/vox/blob/fd6f2abd1b55340e2c5f50551fee939172557825/src/traits.rs#L38-L114)). Universal Realtime STT keeps transport mapping inside provider adapters ([`stt_provider.py:76-108`](https://github.com/Chronica-Anima/universal-realtime-stt/blob/c3ce5b164154b10d6b46b58f24bb0c5714ed4b21/universal_realtime_stt_tts/stt_provider.py#L76-L108)). Dalston defines canonical request and transcript types before engine adapters ([`base.py:170-213`](https://github.com/ssarunic/dalston/blob/04c99b307d7b7563c6e7be711b1f48447cde9814/dalston/realtime_sdk/base.py#L170-L213), [`base_transcribe.py:24-135`](https://github.com/ssarunic/dalston/blob/04c99b307d7b7563c6e7be711b1f48447cde9814/dalston/realtime_sdk/base_transcribe.py#L24-L135)). Keyless corroborates the boundary with its vendor-neutral transcriber contract ([`transcriber.rs:36-68`](https://github.com/hate/keyless/blob/4cefbea3755b6ad10757cc713b08fe639f40f9b6/keyless-whisper/src/transcriber.rs#L36-L68)). + +Replace direct whisper.cpp assumptions in `gateway-transcribe/src/engine.rs`, `worker.rs`, and `final_pass.rs` with batch and realtime transcription interfaces. Keep the sliding window, silence segmentation, stable prefix, unstable suffix, and accurate final pass in the pipeline above those interfaces. Implement whisper.cpp first and add no public backend selector until a second adapter exists. Confidence: high - four references converge on the same boundary, and it directly addresses the highest-leverage deficit. + +### Finding 2: Bound every queue and make overload a protocol outcome + +GigaSTT ties inference capacity to an owned pool permit ([`inference/pool.rs`](https://github.com/ekhodzitsky/gigastt/blob/da75d72bcbcf8b1ec908648ef0be29664983f436/crates/gigastt-core/src/inference/pool.rs)). Vox exposes queue and parallelism limits ([`streaming_pipeline.rs:40-217`](https://github.com/mrtozner/vox/blob/fd6f2abd1b55340e2c5f50551fee939172557825/src/streaming_pipeline.rs#L40-L217)). Keyless deliberately chooses loss behavior at bounded audio ingress ([`cpal.rs:134-186`](https://github.com/hate/keyless/blob/4cefbea3755b6ad10757cc713b08fe639f40f9b6/keyless-audio/src/input/cpal.rs#L134-L186)). Universal Realtime STT uses timed producer backpressure ([`stream_wav.py:17-31`](https://github.com/Chronica-Anima/universal-realtime-stt/blob/c3ce5b164154b10d6b46b58f24bb0c5714ed4b21/helpers/stream_wav.py#L17-L31)). Dalston measures lag in audio time and terminates after warning plus grace ([`session.py:1406-1564`](https://github.com/ssarunic/dalston/blob/04c99b307d7b7563c6e7be711b1f48447cde9814/dalston/realtime_sdk/session.py#L1406-L1564)). + +Replace the growing audio vectors and unbounded worker channels in `gateway-stt/src/stt.rs`, `gateway-transcribe/src/worker.rs`, and `final_pass.rs` with declared limits. Apply backpressure before admission, retain final results ahead of disposable hypotheses, and return a retryable structured overload event when latency or memory crosses the budget. Confidence: high - every reference independently treats unbounded pressure as a correctness problem. + +### Finding 3: Give every stream an owned session and completion guard + +Vox gives each native stream a drop-safe owner ([`sherpa_streaming.rs:93-292`](https://github.com/mrtozner/vox/blob/fd6f2abd1b55340e2c5f50551fee939172557825/src/stt/sherpa_streaming.rs#L93-L292)). GigaSTT makes scarce capacity an RAII-owned resource ([`inference/pool.rs`](https://github.com/ekhodzitsky/gigastt/blob/da75d72bcbcf8b1ec908648ef0be29664983f436/crates/gigastt-core/src/inference/pool.rs)). Dalston centralizes session allocation, keepalive, release, and finalization ([`realtime_proxy.py:79-254`](https://github.com/ssarunic/dalston/blob/04c99b307d7b7563c6e7be711b1f48447cde9814/dalston/gateway/services/realtime_proxy.py#L79-L254)). + +The final worker in `gateway-transcribe/src/final_pass.rs` currently owns one mutable transcript and one completion channel for the current take. Replace that global take with an owned handle keyed by connection and item. Its drop path must cancel queued work, remove accumulated transcript state, close completion delivery, and return capacity. This is required before multiple realtime clients are safe. Confidence: high - ownership is the convergent protection against leaks and cross-session corruption. + +### Finding 4: Publish model generations atomically + +GigaSTT builds one engine aggregate and swaps it through `ArcSwap`, so requests see either the old complete generation or the new one ([`state.rs`](https://github.com/ekhodzitsky/gigastt/blob/da75d72bcbcf8b1ec908648ef0be29664983f436/crates/gigastt/src/server/http/state.rs)). Dalston uses validated capability metadata during worker selection ([`engine.yaml:1-46`](https://github.com/ssarunic/dalston/blob/04c99b307d7b7563c6e7be711b1f48447cde9814/engines/stt-transcribe/faster-whisper/engine.yaml#L1-L46), [`_realtime_common.py:149-300`](https://github.com/ssarunic/dalston/blob/04c99b307d7b7563c6e7be711b1f48447cde9814/dalston/gateway/api/v1/_realtime_common.py#L149-L300)). + +`gateway-stt/src/runtime.rs` should publish the engine, physical model identities, logical pipeline identity, capabilities, and generation as one immutable snapshot. A profile switch should let an existing session finish against its captured generation or send a typed terminal event; it must not silently clear a take. Confidence: medium - GigaSTT supplies the exact atomic mechanism, while Dalston corroborates capability-driven selection but exhibits metadata drift. + +### Finding 5: Keep one typed native protocol and translate only at edges + +Dalston keeps provider-neutral events inside the realtime core and translates compatibility dialects at the public route ([`protocol.py:60-393`](https://github.com/ssarunic/dalston/blob/04c99b307d7b7563c6e7be711b1f48447cde9814/dalston/realtime_sdk/protocol.py#L60-L393), [`realtime.py:1100-1229`](https://github.com/ssarunic/dalston/blob/04c99b307d7b7563c6e7be711b1f48447cde9814/dalston/gateway/api/v1/realtime.py#L1100-L1229)). GigaSTT versions its capability handshake and typed failures ([`protocol/mod.rs`](https://github.com/ekhodzitsky/gigastt/blob/da75d72bcbcf8b1ec908648ef0be29664983f436/crates/gigastt-core/src/protocol/mod.rs)). Universal Realtime STT records transport policy and normalizes provider events through one pump ([ADR 0001](https://github.com/Chronica-Anima/universal-realtime-stt/blob/c3ce5b164154b10d6b46b58f24bb0c5714ed4b21/doc/adr/0001%20Use%20Official%20SDKs%20for%20ElevenLabs%20and%20Speechmatics%20STT.md), [`_event_queue.py:12-119`](https://github.com/Chronica-Anima/universal-realtime-stt/blob/c3ce5b164154b10d6b46b58f24bb0c5714ed4b21/universal_realtime_stt_tts/_event_queue.py#L12-L119)). + +Define one native transcription event model for session creation, stable text, revisable hypotheses, completion, failure, overload, profile change, and termination. Translate it to the OpenAI Realtime subset at Gateway's public edge. Workshop should relay those public events and map them into UI text locally, so `gateway-stt/src/stt.rs` contains no `Push`, `Activity`, `workshop_status`, or Workshop-specific header. Keep Rust and TypeScript aligned through shared fixtures instead of handwritten parallel schemas. Confidence: high - three references separate native meaning from boundary dialects. + +### Finding 6: Make shutdown ordered, bounded, and observable + +GigaSTT cancels producers before draining tasks under a deadline ([`listen.rs`](https://github.com/ekhodzitsky/gigastt/blob/da75d72bcbcf8b1ec908648ef0be29664983f436/crates/gigastt/src/server/listen.rs)). Vox pairs cancellation signals with owned completion handles ([`live_talk.rs:24-515`](https://github.com/mrtozner/vox/blob/fd6f2abd1b55340e2c5f50551fee939172557825/src/server/live_talk.rs#L24-L515)). Dalston's shared proxy core owns allocation through final release ([`realtime_proxy.py:79-254`](https://github.com/ssarunic/dalston/blob/04c99b307d7b7563c6e7be711b1f48447cde9814/dalston/gateway/services/realtime_proxy.py#L79-L254)). + +Replace the unbounded strong-count wait in `gateway-stt/src/runtime.rs` with a shutdown sequence: reject new audio, cancel session producers, close worker queues, await final jobs under a deadline, send one terminal outcome, and release model state. Native inference may remain non-preemptible, but it must not hold the process or a session forever. Confidence: high - the references agree on ownership and order even where native cancellation remains impossible. + +### Finding 7: Test the provider contract without native models + +Universal Realtime STT drives provider orchestration through deterministic doubles ([`tests/test_unit.py:24-263`](https://github.com/Chronica-Anima/universal-realtime-stt/blob/c3ce5b164154b10d6b46b58f24bb0c5714ed4b21/tests/test_unit.py#L24-L263)). GigaSTT enforces runtime-factory isolation in CI ([`runtime/factory.rs`](https://github.com/ekhodzitsky/gigastt/blob/da75d72bcbcf8b1ec908648ef0be29664983f436/crates/gigastt-core/src/runtime/factory.rs), [CI lines 212-222](https://github.com/ekhodzitsky/gigastt/blob/da75d72bcbcf8b1ec908648ef0be29664983f436/.github/workflows/ci.yml#L212-L222)). + +Add a fake backend that runs in every CI job and deterministically covers batch selection, hypothesis replacement, stable deltas, completion, overload, cancellation, profile changes, and concurrent session isolation. Keep real Whisper model tests as a second integration tier, not the only proof of critical behavior. Confidence: high - this directly closes PromptForge's skipped-test gap without weakening native coverage. + +## Provenance + +Five cited Dalston mechanisms carry explicit AI markers. Its canonical transcript contract was AI-originated with no pre-AI form; the edge translators at HEAD tightened an older proxy form from `ddd1b11a181683a7dcb1c748226267b9ec0540ed`; the lag budget remained present against `77316618ab802563f823bc336e71522c6dc3def1`; bounded ingress was added after that same earlier session form; and capability metadata tightened from `eade4b733a0bdaa164e5f744703f2755e24a5ebb`. GigaSTT, Vox, and Keyless cited mechanisms carry strong human signals; Universal Realtime STT is mixed between strong human signal and unknown. + +## Where the subject already matches or beats the references + +- PromptForge isolates blocking native inference on dedicated threads as cleanly as Keyless and more clearly than Vox's cancellation path. +- PromptForge's `gateway-whisper-ffi` RAII wrappers provide stronger native pointer and dynamic-library lifetime ownership than any shortlisted reference exposed. +- PromptForge's generation-aware `stream`, `interim`, and `final` frames already reject stale restart output, a stronger explicit invariant than the small provider adapter projects. +- PromptForge preserves source errors through FFI, transcription, and Gateway layers, while Vox collapses many backend failures to strings. +- PromptForge's worker and runtime ownership is structural; it should preserve that strength when adding bounded session handles. + +## Messes we should explicitly not copy + +- GigaSTT leaves timed-out native inference detached while retaining its pool slot, silently ignores malformed controls, and keeps protocol drift checks advisory. +- Vox concentrates four protocols in a 1,429-line WebSocket module, cannot interrupt blocking inference, and erases backend error structure. +- Keyless leaks desktop bridge threads across pipeline restarts, treats log strings as an internal protocol, and can let finalization overtake queued audio. +- Universal Realtime STT can lose terminal sentinels and final transcripts when queues fill, suppresses sender failures, and cannot terminate one provider's blocking shutdown thread. +- Dalston concentrates session responsibilities in a 1,621-line module, unloads engines without awaiting active sessions, and advertises streaming capability that its runtime does not implement. + +## Recommended execution order + +1. Define native batch, realtime, session, hypothesis, terminal, and overload contracts, then add the deterministic fake backend. This establishes Findings 1, 5, and 7 before moving behavior. +2. Refactor whisper.cpp behind the backend contract without changing the existing two-model algorithm. Verify one-shot and realtime equivalence. +3. Replace global final-take state with owned per-session handles and add concurrent-client tests from Finding 3. +4. Add bounded audio, worker, and admission queues with explicit overload behavior from Finding 2. +5. Publish runtime generations atomically and define profile-switch outcomes from Finding 4. +6. Add ordered bounded shutdown from Finding 6. +7. Translate the native events to the OpenAI Realtime endpoint, make Workshop an opaque relay, and derive UI status locally. + +## Refactor notes + +Findings 1 and 5 begin as structural changes, but backend substitution, protocol translation, queue limits, and switch outcomes change behavior and require characterization tests first. Preserve `gateway-whisper-ffi` ownership and the two-model stable-plus-unstable algorithm. Do not place Workshop types or status text in Gateway crates. Commit each execution-order item after its focused tests pass; two consecutive failures on one item stop the run for a re-plan. The full test suite is the invariant and moves last. + +## Sources + +- GigaSTT, https://github.com/ekhodzitsky/gigastt, `da75d72bcbcf8b1ec908648ef0be29664983f436`, MIT, analyzed 2026-09-05. +- Vox, https://github.com/mrtozner/vox, `fd6f2abd1b55340e2c5f50551fee939172557825`, MIT OR Apache-2.0, analyzed 2026-09-05. +- Keyless, https://github.com/hate/keyless, `4cefbea3755b6ad10757cc713b08fe639f40f9b6`, MIT, analyzed 2026-09-05. +- Universal Realtime STT, https://github.com/Chronica-Anima/universal-realtime-stt, `c3ce5b164154b10d6b46b58f24bb0c5714ed4b21`, MIT, analyzed 2026-09-05. +- Dalston, https://github.com/ssarunic/dalston, `04c99b307d7b7563c6e7be711b1f48447cde9814`, Apache-2.0, analyzed 2026-09-05. Rewinds: `ddd1b11a181683a7dcb1c748226267b9ec0540ed`, `77316618ab802563f823bc336e71522c6dc3def1`, `eade4b733a0bdaa164e5f744703f2755e24a5ebb`. +- Field survey and PromptForge profile produced 2026-09-05. + +*2026-09-05 07:55 - GPT-5.6 Sol*