From 03b078d123621b13edff0bea839e139d7093a10e Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sat, 5 Sep 2026 12:50:55 -0700 Subject: [PATCH 01/86] Split Workshop route tests from handlers Move asset and gateway-config route tests into child modules so the production modules stay below their ratchet ceilings without changing behavior. Record the measured parent and test-module sizes in `module-ceilings.toml`. --- crates/workshop-server/module-ceilings.toml | 9 +- crates/workshop-server/src/routes/assets.rs | 96 +------ .../src/routes/assets/tests.rs | 93 +++++++ .../src/routes/gateway_config.rs | 246 +----------------- .../src/routes/gateway_config/tests.rs | 243 +++++++++++++++++ 5 files changed, 345 insertions(+), 342 deletions(-) create mode 100644 crates/workshop-server/src/routes/assets/tests.rs create mode 100644 crates/workshop-server/src/routes/gateway_config/tests.rs diff --git a/crates/workshop-server/module-ceilings.toml b/crates/workshop-server/module-ceilings.toml index 6397da7b..8df8614c 100644 --- a/crates/workshop-server/module-ceilings.toml +++ b/crates/workshop-server/module-ceilings.toml @@ -128,7 +128,10 @@ # more tests. "resolve.rs" = 553 "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,7 +140,9 @@ # 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 "routes/health.rs" = 50 # New module: the same-origin capability and WebSocket relay from the # Workshop listener to the gateway-owned STT routes. 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..d6fef18f 100644 --- a/crates/workshop-server/src/routes/gateway_config.rs +++ b/crates/workshop-server/src/routes/gateway_config.rs @@ -187,248 +187,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..3d629ce7 --- /dev/null +++ b/crates/workshop-server/src/routes/gateway_config/tests.rs @@ -0,0 +1,243 @@ +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"); +} From 97856d0e41f366312e8dd92a15ccbbe77f677672 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sat, 5 Sep 2026 14:47:58 -0700 Subject: [PATCH 02/86] Replace the `serve` verb with root serving and `--config` The gateway command line has one job, to serve, so the `serve` subcommand and the positional config argument come out: the bare invocation serves with boot discovery, and `--config PATH` names an explicit config, winning over `PROMPTFORGE_GATEWAY_CONFIG`. `parse_args` drops its subcommand match and rejects any non-flag argument as a usage error; every repository-owned caller - the systemd unit, the installer, the Workshop launcher, the tray autostart entries, the release-test workflow, and the guides - moves to the new shape in the same change. - `init_logging` now runs after the already-running handoff check, so `--help`, `--version`, and a second-instance handoff never rotate the running gateway's log; the handoff's browser-open failure reports through `eprintln!` because no subscriber is installed yet. - `--version` is accepted at any position in the argument list, and a second `--config` is a usage error. - On the handoff path in `relaunch.rs`, the connection-file resolution warning is dropped with no subscriber installed; the boot that follows logs its own failure once logging is live. Plan: 2026-09-05-1-gateway-logging-cli --- .github/workflows/gateway-release-test.yml | 2 +- crates/gateway/README.md | 6 +- crates/gateway/packaging/gateway.service | 2 +- crates/gateway/src/boot.rs | 17 +- crates/gateway/src/main.rs | 210 ++++++++++++--------- crates/gateway/src/relaunch.rs | 5 + crates/gateway/src/tray/logic.rs | 30 +-- crates/gateway/tests/it/boot.rs | 185 +++++++++++++++++- crates/workshop/installer.nsi | 6 +- crates/workshop/src/gateway.rs | 7 +- guide/promptforge-gateway-guide.md | 10 +- guide/src/gateway/01-install-and-run.md | 10 +- vibe/2026-09-05-1-gateway-logging-cli.md | 121 ++++++++++++ vibe/ACTIVE | 1 + 14 files changed, 476 insertions(+), 136 deletions(-) create mode 100644 vibe/2026-09-05-1-gateway-logging-cli.md create mode 100644 vibe/ACTIVE 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/crates/gateway/README.md b/crates/gateway/README.md index 4fdd3297..10a1f02e 100644 --- a/crates/gateway/README.md +++ b/crates/gateway/README.md @@ -15,10 +15,10 @@ 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`. @@ -26,7 +26,7 @@ Embedding hosts use the library API instead of the binary: `spawn` starts the ga ## 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. 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..4262d4c1 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}; diff --git a/crates/gateway/src/main.rs b/crates/gateway/src/main.rs index 279f30a2..338ae7b7 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, @@ -25,9 +25,10 @@ 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 --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", "--no-tray run headless (Ctrl-C driven); for servers and CI\n", @@ -72,32 +73,30 @@ 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(); - // 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. + init_logging(); + let result = if invocation.print_url { run_printing_url(&invocation.serve) } else if invocation.tray { @@ -212,29 +211,18 @@ 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())), - } - let mut profile: Option = None; let mut config_path: Option = None; let mut tray = true; @@ -244,6 +232,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 +257,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() + ))); } } } @@ -288,12 +283,12 @@ fn parse_args(args: impl IntoIterator) -> Result" 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 +505,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 +522,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 +530,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 +541,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 +585,7 @@ mod tests { #[test] fn rejects_unknown_flag() { - let error = - parse_args(args(&["serve", "--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(); + let error = parse_args(args(&["--profiles-dir", "x", "--profile", "dev"])).unwrap_err(); assert!(matches!(error, ParseError::Usage(_))); } } 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/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..1ebce8a9 100644 --- a/crates/gateway/tests/it/boot.rs +++ b/crates/gateway/tests/it/boot.rs @@ -212,11 +212,11 @@ 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` 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. #[test] fn headless_serve_writes_the_startup_line_to_the_log_file() { let temp = tempfile::tempdir().unwrap(); @@ -231,7 +231,7 @@ fn headless_serve_writes_the_startup_line_to_the_log_file() { .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("--no-tray") .env("USERPROFILE", temp.path()) @@ -264,6 +264,179 @@ fn headless_serve_writes_the_startup_line_to_the_log_file() { ); } +/// 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" + ); + } +} + /// A config with two profiles over one backend, so a switch from `main` to /// `other` exercises the switch machinery against the slow backend. fn two_profile_config(backend: std::net::SocketAddr) -> String { 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..99183b4e 100644 --- a/crates/workshop/src/gateway.rs +++ b/crates/workshop/src/gateway.rs @@ -202,14 +202,13 @@ fn wait_for_launched_file(run_dir: &Path, timeout: Duration) -> anyhow::Result 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()); diff --git a/guide/promptforge-gateway-guide.md b/guide/promptforge-gateway-guide.md index 7e9c4628..45c0f42a 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 ```` diff --git a/guide/src/gateway/01-install-and-run.md b/guide/src/gateway/01-install-and-run.md index c5aad568..745eec88 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 ```` 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..5b9e9a15 --- /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: pending + - id: logging-diagnostics + content: Add diagnostics, retention, fatal-chain capture, privacy, and pressure behavior + status: pending + - id: logging-verify + content: Update rules/docs and complete full verification + status: pending + - 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/ACTIVE b/vibe/ACTIVE new file mode 100644 index 00000000..86478dfa --- /dev/null +++ b/vibe/ACTIVE @@ -0,0 +1 @@ +2026-09-05-1-gateway-logging-cli From f303718eb626c6ceecee2b5ec1c8ab0d88aede51 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sat, 5 Sep 2026 17:04:34 -0700 Subject: [PATCH 03/86] Extract gateway-logging crate with bounded priority queue Move the log pipeline out of `crates/gateway/src/main.rs` into a new `gateway-logging` crate so the queue, rotation, sink, and worker lifecycle are owned and tested in one place. The crate exports `LogConfig`, `LogRuntime`, `LogWriter`, and the opaque `LogError`; `LogRuntime::start` rotates and opens `gateway.log`, spawns one worker thread, and `shutdown` closes admission, drains, flushes, and joins. `main.rs` keeps global subscriber installation, holds the returned `LogRuntime`, and shuts the logger down last so fatal error chains logged through `log_error_chain` reach the disk. - Queue policy is fixed in `queue.rs`: `CAPACITY` of 8192 records, drain `BATCH` of 256, one deque per `LogPriority` under one mutex. A full queue evicts the oldest Debug, then Trace, then Info; Warn and Error records are never evicted, and a producer with no eligible record blocks on a condition variable. - `LogEventWriter` buffers every `Write` call for one event and enqueues on `Drop`, moving the buffer through `String::from_utf8` and paying the lossy copy only for invalid UTF-8. It is public but `#[doc(hidden)]` because `MakeWriter::Writer` cannot name a private type. - A failed write or flush on the file sink falls back to synchronous stderr, and a worker panic surfaces from `shutdown` as a `LogError` that `is_io` classifies separately from filesystem and spawn failures. - Rotation keeps one previous run: an existing `gateway.log` renames to `gateway.log.1`, overwriting the older rotation. Plan: 2026-09-05-1-gateway-logging-cli --- Cargo.lock | 9 + Cargo.toml | 1 + crates/gateway-logging/AGENTS.md | 9 + crates/gateway-logging/Cargo.toml | 17 + crates/gateway-logging/src/config.rs | 49 +++ crates/gateway-logging/src/error.rs | 112 +++++ crates/gateway-logging/src/lib.rs | 27 ++ crates/gateway-logging/src/queue.rs | 512 +++++++++++++++++++++++ crates/gateway-logging/src/runtime.rs | 185 ++++++++ crates/gateway-logging/src/worker.rs | 167 ++++++++ crates/gateway-logging/src/writer.rs | 175 ++++++++ crates/gateway/Cargo.toml | 3 + crates/gateway/src/main.rs | 117 +++--- vibe/2026-09-05-1-gateway-logging-cli.md | 2 +- 14 files changed, 1315 insertions(+), 70 deletions(-) create mode 100644 crates/gateway-logging/AGENTS.md create mode 100644 crates/gateway-logging/Cargo.toml create mode 100644 crates/gateway-logging/src/config.rs create mode 100644 crates/gateway-logging/src/error.rs create mode 100644 crates/gateway-logging/src/lib.rs create mode 100644 crates/gateway-logging/src/queue.rs create mode 100644 crates/gateway-logging/src/runtime.rs create mode 100644 crates/gateway-logging/src/worker.rs create mode 100644 crates/gateway-logging/src/writer.rs diff --git a/Cargo.lock b/Cargo.lock index 1b04c532..55c04dca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1880,6 +1880,7 @@ dependencies = [ "gateway-config", "gateway-config-ui", "gateway-local", + "gateway-logging", "gateway-routing", "gateway-stt", "gateway-web-search", @@ -1972,6 +1973,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" diff --git a/Cargo.toml b/Cargo.toml index 5cd7b6d0..8d6478de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,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" } diff --git a/crates/gateway-logging/AGENTS.md b/crates/gateway-logging/AGENTS.md new file mode 100644 index 00000000..4e1e3c2c --- /dev/null +++ b/crates/gateway-logging/AGENTS.md @@ -0,0 +1,9 @@ +# gateway-logging + +This crate owns the gateway's log pipeline: the bounded priority queue, the `gateway.log` rotation and file sink, 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 public surface is exactly `LogConfig`, `LogRuntime`, `LogWriter`, and the opaque `LogError`. Queue lanes, records, rotation, sinks, mutexes, condition variables, and worker handles stay private. +- 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. +- File-sink failure falls back to synchronous stderr. `LogRuntime::shutdown` closes admission, drains, flushes, and joins - the gateway shuts the logger down last. 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..700e0a85 --- /dev/null +++ b/crates/gateway-logging/src/config.rs @@ -0,0 +1,49 @@ +//! The configuration input for [`LogRuntime::start`](crate::LogRuntime::start). + +use std::path::{Path, PathBuf}; + +/// 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 + } + + /// 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 + } +} 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..9ccce95d --- /dev/null +++ b/crates/gateway-logging/src/lib.rs @@ -0,0 +1,27 @@ +//! Bounded, prioritized file logging for the PromptForge gateway. +//! +//! [`LogRuntime`] owns one worker thread that drains a fixed-capacity +//! priority queue into a rotated `gateway.log`; [`LogWriter`] adapts the +//! queue to `tracing-subscriber`'s `MakeWriter` so the binary's fmt layer +//! enqueues formatted 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 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; diff --git a/crates/gateway-logging/src/queue.rs b/crates/gateway-logging/src/queue.rs new file mode 100644 index 00000000..e6ccc8e7 --- /dev/null +++ b/crates/gateway-logging/src/queue.rs @@ -0,0 +1,512 @@ +//! 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::{AtomicU64, Ordering}; +use std::sync::{Condvar, Mutex, PoisonError}; + +/// 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) done: bool, +} + +/// 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, + next_sequence: AtomicU64, +} + +#[derive(Debug)] +struct State { + lanes: [VecDeque; 5], + len: usize, + closed: bool, + evicted: [u64; 5], +} + +impl State { + fn push(&mut self, record: LogRecord) { + self.lanes[record.priority.lane()].push_back(record); + self.len += 1; + } + + /// Evicts the oldest record the incoming priority is allowed to + /// displace, counting the eviction by the evicted record's level. + 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.evicted[record.priority.lane()] += 1; + self.len -= 1; + return Some(record); + } + } + None + } + + /// Pops the lane head with the smallest global sequence, so drained + /// output stays chronological across lanes. + fn pop_oldest(&mut 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), + } + } + let index = oldest?; + let record = self.lanes[index].pop_front(); + self.len -= 1; + record + } + + /// Builds the one synthetic summary of a pressure episode and resets + /// the counters; `None` when nothing was evicted since the last + /// summary. + fn take_summary(&mut self) -> Option> { + if self.evicted.iter().all(|&count| count == 0) { + return None; + } + let total: u64 = self.evicted.iter().sum(); + let summary = format!( + "log pressure dropped {total} record(s): debug={}, trace={}, info={}\n", + self.evicted[LogPriority::Debug.lane()], + self.evicted[LogPriority::Trace.lane()], + self.evicted[LogPriority::Info.lane()], + ) + .into_boxed_str(); + self.evicted = [0; 5]; + Some(summary) + } +} + +impl LogQueue { + pub(crate) fn new() -> Self { + Self { + state: Mutex::new(State { + lanes: std::array::from_fn(|_| VecDeque::new()), + len: 0, + closed: false, + evicted: [0; 5], + }), + work_available: Condvar::new(), + space_available: Condvar::new(), + next_sequence: AtomicU64::new(0), + } + } + + /// Enqueues `line`, assigning its global sequence before the lock is + /// taken so formatting and allocation never happen under the mutex. On + /// a full queue the oldest eligible lower-priority record is evicted; + /// with none eligible the producer blocks on the condition variable + /// until the worker frees space. After [`close`](Self::close) new + /// records are dropped. + pub(crate) fn enqueue(&self, priority: LogPriority, line: Box) { + let sequence = self.next_sequence.fetch_add(1, Ordering::Relaxed); + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + loop { + if state.closed { + return; + } + if state.len < CAPACITY { + state.push(LogRecord { + sequence, + priority, + line, + }); + drop(state); + self.work_available.notify_one(); + return; + } + if state.evict_for(priority).is_some() { + state.push(LogRecord { + sequence, + priority, + line, + }); + drop(state); + self.work_available.notify_one(); + return; + } + state = self + .space_available + .wait(state) + .unwrap_or_else(PoisonError::into_inner); + } + } + + /// 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 when the queue empties + /// after evictions. 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 { + if state.len == 0 && !state.closed { + state = self + .work_available + .wait(state) + .unwrap_or_else(PoisonError::into_inner); + continue; + } + let mut records = Vec::with_capacity(BATCH.min(state.len)); + while records.len() < BATCH { + let Some(record) = state.pop_oldest() else { + break; + }; + records.push(record); + } + let summary = if state.len == 0 { + state.take_summary() + } else { + None + }; + let done = state.closed && state.len == 0; + drop(state); + self.space_available.notify_all(); + return Batch { + records, + summary, + 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 + } + + /// Closes admission and wakes every waiter: producers drop new + /// records, blocked producers return, and the worker exits once the + /// queue drains. + pub(crate) fn close(&self) { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + state.closed = true; + drop(state); + self.work_available.notify_all(); + self.space_available.notify_all(); + } +} + +#[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 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_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 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"); + } +} diff --git a/crates/gateway-logging/src/runtime.rs b/crates/gateway-logging/src/runtime.rs new file mode 100644 index 00000000..201fdee8 --- /dev/null +++ b/crates/gateway-logging/src/runtime.rs @@ -0,0 +1,185 @@ +//! The owning handle: queues, sink, rotation, and the worker thread's +//! lifecycle. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::thread::JoinHandle; + +use crate::config::LogConfig; +use crate::error::LogError; +use crate::queue::LogQueue; +use crate::worker::{LogWorker, open_log_file}; +use crate::writer::LogWriter; + +/// 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 the final records still reach the disk. +/// +/// # 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, +} + +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, drains every queued record, flushes the sink, and + /// joins the worker thread. 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(mut self) -> Result<(), LogError> { + self.queue.close(); + if let Some(worker) = self.worker.take() { + worker.join().map_err(|_| LogError::worker_panicked())?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write as _; + 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 performs the same rotation the binary used to" + ); + runtime.shutdown().expect("shutdown"); + } +} diff --git a/crates/gateway-logging/src/worker.rs b/crates/gateway-logging/src/worker.rs new file mode 100644 index 00000000..a773adbb --- /dev/null +++ b/crates/gateway-logging/src/worker.rs @@ -0,0 +1,167 @@ +//! The worker thread, the file sink with its stderr fallback, and the log +//! rotation performed before the fresh file opens. + +use std::fs::File; +use std::io::{self, BufWriter, Write as _}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::thread::JoinHandle; + +use crate::queue::LogQueue; + +/// 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. +pub(crate) fn open_log_file(state_dir: &Path) -> io::Result<(PathBuf, 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)?; + } + let file = File::create(¤t)?; + Ok((current, file)) +} + +/// The drain target: the rotated log file until a write fails, then +/// synchronous stderr so records still land somewhere. +#[derive(Debug)] +enum Sink { + File(BufWriter), + Stderr, +} + +impl Sink { + fn write_line(&mut self, line: &str) { + match self { + 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()); + } + } + } + + fn flush(&mut self) { + match self { + 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(); + } + } + } +} + +/// The worker owner: spawned by +/// [`LogRuntime::start`](crate::LogRuntime::start), joined by +/// [`LogRuntime::shutdown`](crate::LogRuntime::shutdown). +pub(crate) struct LogWorker; + +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: File) -> io::Result> { + std::thread::Builder::new() + .name("gateway-logging".to_string()) + .spawn(move || { + let mut sink = Sink::File(BufWriter::new(file)); + loop { + let batch = queue.take_batch(); + for record in &batch.records { + sink.write_line(&record.line); + } + if let Some(summary) = &batch.summary { + sink.write_line(summary); + } + sink.flush(); + if batch.done { + break; + } + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + 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_keeps_one_previous_run() { + 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" + ); + + std::fs::write(&path, "second run").expect("write second run"); + let (_path, file) = open_log_file(&temp.0).expect("second rotation opens"); + drop(file); + assert_eq!( + std::fs::read_to_string(temp.0.join("logs/gateway.log.1")).expect("rotated log"), + "second run", + "a second rotation overwrites the older .1" + ); + } +} diff --git a/crates/gateway-logging/src/writer.rs b/crates/gateway-logging/src/writer.rs new file mode 100644 index 00000000..2454818e --- /dev/null +++ b/crates/gateway-logging/src/writer.rs @@ -0,0 +1,175 @@ +//! The `MakeWriter` adapter between the binary's fmt layer and the queue. + +use std::io; +use std::sync::Arc; + +use tracing::Metadata; +use tracing_subscriber::fmt::MakeWriter; + +use crate::queue::{LogPriority, LogQueue}; + +/// A cloneable factory that hands the fmt layer per-event writers feeding +/// the queue. +/// +/// Priority comes only from the event's tracing metadata; the writer never +/// inspects the formatted text. 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 _clone = writer.clone(); +/// 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()), + ) + } +} + +/// 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: Vec, +} + +impl LogEventWriter { + fn new(queue: Arc, priority: LogPriority) -> Self { + Self { + queue, + priority, + buffer: Vec::new(), + } + } +} + +impl io::Write for LogEventWriter { + fn write(&mut self, buffer: &[u8]) -> io::Result { + self.buffer.extend_from_slice(buffer); + Ok(buffer.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +impl Drop for LogEventWriter { + fn drop(&mut self) { + if self.buffer.is_empty() { + return; + } + // The formatter's output is almost always valid UTF-8, so move the + // buffer into the record and pay the lossy copy only when it is not. + let line = match String::from_utf8(std::mem::take(&mut self.buffer)) { + Ok(text) => text.into_boxed_str(), + Err(error) => String::from_utf8_lossy(error.as_bytes()) + .into_owned() + .into_boxed_str(), + }; + self.queue.enqueue(self.priority, line); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write as _; + + #[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 + ); + } +} diff --git a/crates/gateway/Cargo.toml b/crates/gateway/Cargo.toml index 0d6a406f..5343b274 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, diff --git a/crates/gateway/src/main.rs b/crates/gateway/src/main.rs index 338ae7b7..20bb8575 100644 --- a/crates/gateway/src/main.rs +++ b/crates/gateway/src/main.rs @@ -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; @@ -95,7 +96,7 @@ fn main() -> ExitCode { // 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. - init_logging(); + let logging = init_logging(); let result = if invocation.print_url { run_printing_url(&invocation.serve) @@ -104,77 +105,85 @@ fn main() -> ExitCode { } else { run(&invocation.serve) }; - match result { + let exit = match result { Ok(()) => 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); + } else { + print_error_chain(&error); + } ExitCode::FAILURE } - } + }; + // The logger shuts down last, so the terminal outcome and every record + // behind it drain to the disk before the process exits. + 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_layer = tracing_subscriber::fmt::layer() .with_ansi(false) - .with_writer(std::sync::Mutex::new(file)) + .with_writer(runtime.writer()) .with_filter(filter()); tracing_subscriber::registry() .with(stdout) .with(file_layer) .init(); - tracing::info!("logging to {}", path.display()); + 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(); @@ -330,36 +339,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()) diff --git a/vibe/2026-09-05-1-gateway-logging-cli.md b/vibe/2026-09-05-1-gateway-logging-cli.md index 5b9e9a15..82769d3c 100644 --- a/vibe/2026-09-05-1-gateway-logging-cli.md +++ b/vibe/2026-09-05-1-gateway-logging-cli.md @@ -7,7 +7,7 @@ todos: status: completed - id: logging-crate content: Extract gateway-logging with bounded prioritized worker and shutdown ownership - status: pending + status: completed - id: logging-diagnostics content: Add diagnostics, retention, fatal-chain capture, privacy, and pressure behavior status: pending From 1009b3f4d8029e8a847398b66521584816f78ab7 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sat, 5 Sep 2026 17:59:28 -0700 Subject: [PATCH 04/86] Add diagnostics report, five-run retention, and redaction A failed gateway run must be discoverable without config knowledge, and no log record may carry secret material. The gateway gains a `diagnostics` subcommand that prints a read-only JSON report of the state dir, config, logs, and connection file, the log rotation retains five previous runs, and every queued record crosses a redaction pass that masks bearer tokens, authorization and cookie header values, and `api_key` assignments. - The log layout gets one owner: `LogConfig::log_path` and `LogConfig::retained_log_paths` name every path, so `diagnostics_json` enumerates the logs without starting a runtime and `open_log_file` rotates the same chain. - Redaction sits at the one chokepoint every record crosses: `LogEventWriter::drop` masks the formatted line with `redact_line` before the record enters the queue. - `is_running` in `shared-sidecar` is read-only: a stale or corrupt connection file reads as not-running and stays on disk for the next launch to clean. - `discover_in(explicit, gather)` splits the report's config discovery into a testable inner in the `resolve_in` pattern; unit tests pin the explicit, discovered, profile-fallback, and gather-failure branches, and both `diagnostics` integration tests assert `config.path` and `config.exists`. - `diagnostics` runs before the handoff check and before logging starts; it never serves, rotates a log, parses a config, or mutates the state directory, and `parse_diagnostics_args` accepts only `--config PATH`. - The generated config carries `# Diagnostics: promptforge-gateway diagnostics` as a comment, so the file stays parseable. - New tests pin the sink's stderr fallback on rejected writes and flushes, saturation that never evicts or duplicates Warn or Error records, and a shutdown that writes every record in enqueue order before the join returns. - `Sink::Null` and `Sink::is_stderr` are `#[cfg(test)]` seams for the fallback and latency tests. - `production_logging_stays_within_latency_budget` is `#[ignore]`d; it runs only through `cargo test -p gateway-logging --release -- --ignored`. Plan: 2026-09-05-1-gateway-logging-cli --- crates/gateway-logging/src/config.rs | 68 ++++++ crates/gateway-logging/src/lib.rs | 1 + crates/gateway-logging/src/queue.rs | 78 +++++++ crates/gateway-logging/src/redact.rs | 247 ++++++++++++++++++++ crates/gateway-logging/src/runtime.rs | 27 ++- crates/gateway-logging/src/worker.rs | 228 +++++++++++++++++-- crates/gateway-logging/src/writer.rs | 55 ++++- crates/gateway/src/boot.rs | 87 +++++++ crates/gateway/src/diagnostics.rs | 276 +++++++++++++++++++++++ crates/gateway/src/lib.rs | 2 + crates/gateway/src/main.rs | 166 +++++++++++++- crates/gateway/tests/it/boot.rs | 200 ++++++++++++++++ crates/shared-sidecar/src/lib.rs | 2 +- crates/shared-sidecar/src/stale.rs | 75 ++++++ vibe/2026-09-05-1-gateway-logging-cli.md | 2 +- 15 files changed, 1487 insertions(+), 27 deletions(-) create mode 100644 crates/gateway-logging/src/redact.rs create mode 100644 crates/gateway/src/diagnostics.rs diff --git a/crates/gateway-logging/src/config.rs b/crates/gateway-logging/src/config.rs index 700e0a85..be98e058 100644 --- a/crates/gateway-logging/src/config.rs +++ b/crates/gateway-logging/src/config.rs @@ -2,6 +2,11 @@ use std::path::{Path, PathBuf}; +/// Previous runs retained beside the current log: `gateway.log.1` (the +/// newest rotation) through `gateway.log.5` (the oldest). A sixth +/// previous run is deleted by the rotation that would create it. +pub(crate) const RETAINED_RUNS: usize = 5; + /// The one input logging needs: the gateway state directory that holds /// `logs/`. /// @@ -40,6 +45,45 @@ impl LogConfig { &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 previous-run log 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_RUNS) + .map(|run| { + self.state_dir + .join("logs") + .join(format!("gateway.log.{run}")) + }) + .collect() + } + /// Consumes the config into its state directory, so a one-shot caller /// such as [`LogRuntime::start`](crate::LogRuntime::start) moves /// instead of cloning. @@ -47,3 +91,27 @@ impl LogConfig { self.state_dir } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_log_layout_is_current_plus_five_retained_runs() { + 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/lib.rs b/crates/gateway-logging/src/lib.rs index 9ccce95d..dec1d488 100644 --- a/crates/gateway-logging/src/lib.rs +++ b/crates/gateway-logging/src/lib.rs @@ -13,6 +13,7 @@ mod config; mod error; mod queue; +mod redact; mod runtime; mod worker; mod writer; diff --git a/crates/gateway-logging/src/queue.rs b/crates/gateway-logging/src/queue.rs index e6ccc8e7..c6c3c718 100644 --- a/crates/gateway-logging/src/queue.rs +++ b/crates/gateway-logging/src/queue.rs @@ -509,4 +509,82 @@ mod tests { 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()); + 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..a3e15e88 --- /dev/null +++ b/crates/gateway-logging/src/redact.rs @@ -0,0 +1,247 @@ +//! 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. Most of that list is call-site discipline - +//! the gateway never logs payloads - but the well-shaped secrets (bearer +//! tokens, authorization and cookie header values, `api_key` assignments) +//! can leak through an interpolated error or a debug-formatted structure, +//! so the one chokepoint every record crosses masks them on the way in. +//! +//! The patterns are ASCII and matched case-insensitively where a header +//! name is involved; redaction never reorders or truncates the rest of +//! the line. + +/// Masks the sensitive shapes `text` could carry and returns the result. +/// The input passes through unchanged when nothing matches, which is the +/// common case. +pub(crate) fn redact_line(text: &str) -> String { + let text = redact_header_values(text, "authorization:"); + let text = redact_header_values(&text, "cookie:"); + let text = redact_header_values(&text, "set-cookie:"); + let text = redact_bearer_tokens(&text); + redact_api_key_assignments(&text) +} + +/// The mask replacing a sensitive value. +const REDACTED: &str = "[redacted]"; + +/// 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() { + return None; + } + haystack.as_bytes()[from..] + .windows(needle.len()) + .position(|window| window.eq_ignore_ascii_case(needle.as_bytes())) + .map(|offset| from + offset) +} + +/// Redacts everything after a header name up to the end of the line: an +/// `Authorization:` or `Cookie:` value runs to the line's end in the +/// one-line-per-event format the fmt layer produces. +fn redact_header_values(text: &str, header: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = find_ascii(rest, header, 0) { + let mut value_start = start + header.len(); + // The conventional space after the colon is kept with the name. + while rest.as_bytes().get(value_start) == Some(&b' ') { + value_start += 1; + } + let value_end = rest[value_start..] + .find('\n') + .map_or(rest.len(), |newline| value_start + newline); + out.push_str(&rest[..value_start]); + out.push_str(REDACTED); + rest = &rest[value_end..]; + } + out.push_str(rest); + out +} + +/// Redacts the token after `Bearer `, the shape an authorization value +/// takes when it appears without its header name (an interpolated error, +/// a URL query). The token is the run of non-whitespace following the +/// scheme. +fn redact_bearer_tokens(text: &str) -> String { + const SCHEME: &str = "bearer "; + let mut out = String::with_capacity(text.len()); + let mut rest = text; + let mut from = 0; + while let Some(start) = find_ascii(rest, SCHEME, from) { + let token_start = start + SCHEME.len(); + let token_end = rest[token_start..] + .find(char::is_whitespace) + .map_or(rest.len(), |space| token_start + space); + if token_end == token_start { + from = token_start; + continue; + } + out.push_str(&rest[..token_start]); + out.push_str(REDACTED); + rest = &rest[token_end..]; + from = 0; + } + out.push_str(rest); + out +} + +/// Redacts the value of an `api_key` assignment in the shapes configs and +/// JSON take: `api_key = "v"`, `api_key="v"`, `"api_key": "v"`, and bare +/// `api_key = v`. The key name is kept so the log still says which field +/// was masked. +fn redact_api_key_assignments(text: &str) -> String { + const KEY: &str = "api_key"; + let mut out = String::with_capacity(text.len()); + let mut rest = text; + let mut from = 0; + while let Some(start) = find_ascii(rest, KEY, from) { + let after_key = start + KEY.len(); + let bytes = rest.as_bytes(); + let mut cursor = after_key; + // The JSON shape quotes the key: `"api_key": "v"`. + if cursor < bytes.len() && bytes[cursor] == b'"' { + cursor += 1; + } + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() && bytes[cursor] != b'\n' + { + cursor += 1; + } + // Only an assignment redacts: a bare mention of the field name is + // not a leak. + if cursor >= bytes.len() || (bytes[cursor] != b'=' && bytes[cursor] != b':') { + from = after_key; + continue; + } + cursor += 1; + while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() && bytes[cursor] != b'\n' + { + cursor += 1; + } + let quoted = cursor < bytes.len() && bytes[cursor] == b'"'; + if quoted { + cursor += 1; + } + let value_start = cursor; + let value_end = if quoted { + rest[value_start..] + .find('"') + .map_or(rest.len(), |quote| value_start + quote) + } else { + rest[value_start..] + .find(|c: char| c.is_whitespace() || c == ',') + .map_or(rest.len(), |end| value_start + end) + }; + if value_end == value_start { + from = after_key; + continue; + } + out.push_str(&rest[..value_start]); + out.push_str(REDACTED); + rest = &rest[value_end..]; + from = 0; + } + out.push_str(rest); + out +} + +#[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 an_ordinary_line_passes_through_unchanged() { + let line = "loaded profile main with 2 models; bind 127.0.0.1:8081"; + 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 index 201fdee8..e685b28a 100644 --- a/crates/gateway-logging/src/runtime.rs +++ b/crates/gateway-logging/src/runtime.rs @@ -178,8 +178,33 @@ mod tests { assert_eq!( std::fs::read_to_string(temp.0.join("logs/gateway.log.1")).expect("rotated log"), "previous run", - "start performs the same rotation the binary used to" + "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" + ); + } + } } diff --git a/crates/gateway-logging/src/worker.rs b/crates/gateway-logging/src/worker.rs index a773adbb..c60d5847 100644 --- a/crates/gateway-logging/src/worker.rs +++ b/crates/gateway-logging/src/worker.rs @@ -7,27 +7,36 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::thread::JoinHandle; +use crate::config::{LogConfig, RETAINED_RUNS}; use crate::queue::LogQueue; -/// 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. +/// Opens `/logs/gateway.log` fresh for this run, first shifting +/// the retained chain - `gateway.log.4` to `.5`, down to `gateway.log` to +/// `.1` - and deleting the oldest rotation, so five previous runs are 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. pub(crate) fn open_log_file(state_dir: &Path) -> io::Result<(PathBuf, File)> { + let config = LogConfig::new(state_dir); 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"); + let current = config.log_path(); + let retained = config.retained_log_paths(); 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)?; + // the oldest rotation is removed before the chain shifts. + let oldest = &retained[RETAINED_RUNS - 1]; + if oldest.is_file() { + std::fs::remove_file(oldest)?; } - std::fs::rename(¤t, &previous)?; + for run in (1..RETAINED_RUNS).rev() { + if retained[run - 1].is_file() { + std::fs::rename(&retained[run - 1], &retained[run])?; + } + } + std::fs::rename(¤t, &retained[0])?; } let file = File::create(¤t)?; Ok((current, file)) @@ -39,6 +48,9 @@ pub(crate) fn open_log_file(state_dir: &Path) -> io::Result<(PathBuf, File)> { enum Sink { File(BufWriter), Stderr, + /// The latency test's baseline: every write accepted, nothing done. + #[cfg(test)] + Null, } impl Sink { @@ -56,6 +68,8 @@ impl Sink { Self::Stderr => { let _ = io::stderr().lock().write_all(line.as_bytes()); } + #[cfg(test)] + Self::Null => {} } } @@ -72,8 +86,17 @@ impl Sink { Self::Stderr => { let _ = io::stderr().lock().flush(); } + #[cfg(test)] + Self::Null => {} } } + + /// 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 @@ -113,6 +136,137 @@ impl LogWorker { #[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"); + std::fs::create_dir_all(temp.0.join("logs")).expect("logs dir"); + let file = File::create(temp.0.join("logs/gateway.log")).expect("create the log"); + let file_sink = measure_p95_enqueue_to_write(Sink::File(BufWriter::new(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); @@ -136,7 +290,7 @@ mod tests { } #[test] - fn the_log_rotation_keeps_one_previous_run() { + 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"); @@ -155,13 +309,55 @@ mod tests { "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.0).expect("second rotation opens"); - drop(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!( - std::fs::read_to_string(temp.0.join("logs/gateway.log.1")).expect("rotated log"), - "second run", - "a second rotation overwrites the older .1" + 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" ); } } diff --git a/crates/gateway-logging/src/writer.rs b/crates/gateway-logging/src/writer.rs index 2454818e..d79b012e 100644 --- a/crates/gateway-logging/src/writer.rs +++ b/crates/gateway-logging/src/writer.rs @@ -7,12 +7,14 @@ use tracing::Metadata; use tracing_subscriber::fmt::MakeWriter; use crate::queue::{LogPriority, LogQueue}; +use crate::redact::redact_line; /// A cloneable factory that hands the fmt layer per-event writers feeding /// the queue. /// -/// Priority comes only from the event's tracing metadata; the writer never -/// inspects the formatted text. Obtained from +/// Priority comes only from the event's tracing metadata; the formatted +/// text passes through the privacy redaction before it can reach the +/// queue. Obtained from /// [`LogRuntime::writer`](crate::LogRuntime::writer). /// /// # Examples @@ -95,12 +97,13 @@ impl Drop for LogEventWriter { // The formatter's output is almost always valid UTF-8, so move the // buffer into the record and pay the lossy copy only when it is not. let line = match String::from_utf8(std::mem::take(&mut self.buffer)) { - Ok(text) => text.into_boxed_str(), - Err(error) => String::from_utf8_lossy(error.as_bytes()) - .into_owned() - .into_boxed_str(), + Ok(text) => text, + Err(error) => String::from_utf8_lossy(error.as_bytes()).into_owned(), }; - self.queue.enqueue(self.priority, line); + // The privacy chokepoint: every record crosses here, so the + // well-shaped secrets are masked before they can reach the queue. + self.queue + .enqueue(self.priority, redact_line(&line).into_boxed_str()); } } @@ -172,4 +175,42 @@ mod tests { 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 + ); + } } diff --git a/crates/gateway/src/boot.rs b/crates/gateway/src/boot.rs index 4262d4c1..6bdff28e 100644 --- a/crates/gateway/src/boot.rs +++ b/crates/gateway/src/boot.rs @@ -369,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. @@ -498,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" @@ -623,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"); @@ -692,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/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/lib.rs b/crates/gateway/src/lib.rs index 82b382a0..dfa31ad8 100644 --- a/crates/gateway/src/lib.rs +++ b/crates/gateway/src/lib.rs @@ -82,6 +82,7 @@ mod commands; mod config_apply; mod config_pending; mod config_write; +mod diagnostics; mod dialect; mod drain; mod env_file; @@ -116,6 +117,7 @@ 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; pub use crate::relaunch::running_gateway_settings_url; pub use crate::runner::{ Gateway, GatewayHandle, ProfilesContext, ServeOptions, run, run_printing_url, spawn, diff --git a/crates/gateway/src/main.rs b/crates/gateway/src/main.rs index 20bb8575..d85ffb89 100644 --- a/crates/gateway/src/main.rs +++ b/crates/gateway/src/main.rs @@ -27,11 +27,14 @@ const DEFAULT_LOG_FILTER: &str = "info,whisper_cpp=warn,hyper=warn,h2=warn,reqwe const USAGE: &str = concat!( "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", "--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", @@ -74,6 +77,17 @@ fn main() -> ExitCode { } }; + // 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 // logging starts and before any bind attempt - a handoff must not @@ -204,10 +218,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. @@ -232,6 +258,13 @@ fn parse_args(args: impl IntoIterator) -> Result = None; let mut config_path: Option = None; let mut tray = true; @@ -283,6 +316,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 @@ -567,4 +637,98 @@ mod tests { let error = parse_args(args(&["--profiles-dir", "x", "--profile", "dev"])).unwrap_err(); assert!(matches!(error, ParseError::Usage(_))); } + + #[test] + 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/tests/it/boot.rs b/crates/gateway/tests/it/boot.rs index 1ebce8a9..1d0f85d4 100644 --- a/crates/gateway/tests/it/boot.rs +++ b/crates/gateway/tests/it/boot.rs @@ -437,6 +437,206 @@ fn version_and_help_never_rotate_the_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}" + ); +} + /// A config with two profiles over one backend, so a switch from `main` to /// `other` exercises the switch machinery against the slow backend. fn two_profile_config(backend: std::net::SocketAddr) -> String { diff --git a/crates/shared-sidecar/src/lib.rs b/crates/shared-sidecar/src/lib.rs index 62ad9904..7bcf278a 100644 --- a/crates/shared-sidecar/src/lib.rs +++ b/crates/shared-sidecar/src/lib.rs @@ -50,4 +50,4 @@ 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}; diff --git a/crates/shared-sidecar/src/stale.rs b/crates/shared-sidecar/src/stale.rs index ead72dfe..131097fa 100644 --- a/crates/shared-sidecar/src/stale.rs +++ b/crates/shared-sidecar/src/stale.rs @@ -109,6 +109,26 @@ pub(crate) fn resolve_named(run_dir: &Path, image_name: &str) -> Result 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. @@ -358,4 +378,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/vibe/2026-09-05-1-gateway-logging-cli.md b/vibe/2026-09-05-1-gateway-logging-cli.md index 82769d3c..dd30747a 100644 --- a/vibe/2026-09-05-1-gateway-logging-cli.md +++ b/vibe/2026-09-05-1-gateway-logging-cli.md @@ -10,7 +10,7 @@ todos: status: completed - id: logging-diagnostics content: Add diagnostics, retention, fatal-chain capture, privacy, and pressure behavior - status: pending + status: completed - id: logging-verify content: Update rules/docs and complete full verification status: pending From 4a1136ac10b9de5c282b2e6bef72753b06ed552e Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sat, 5 Sep 2026 19:46:38 -0700 Subject: [PATCH 05/86] Fix rustdoc private links in gateway runner docs The public `serve` docs linked the private `GRACEFUL_DRAIN_TIMEOUT` and `WORKER_JOIN_TIMEOUT` constants, which `RUSTDOCFLAGS="-D warnings" cargo doc` rejects as private intra-doc links. The constants are now plain backticked names. The break was introduced in 7f24bb0c and predates the logging work. --- crates/gateway/src/runner.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/gateway/src/runner.rs b/crates/gateway/src/runner.rs index f19267df..110d4dd9 100644 --- a/crates/gateway/src/runner.rs +++ b/crates/gateway/src/runner.rs @@ -396,9 +396,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. /// From ccb77bf0eb8f86267db66c643302d04390808429 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sat, 5 Sep 2026 19:54:57 -0700 Subject: [PATCH 06/86] Pin gateway-logging dependency boundary, document logging The logging contract now lives in the documentation, and the dependency boundary has a test that enforces it. A new integration test `the_manifest_declares_only_the_tracing_dependencies` reads the crate's own `Cargo.toml` and fails when any dependency other than `tracing` and `tracing-subscriber` appears. The `gateway-logging` `AGENTS.md`, the gateway `README.md`, and both gateway guides now describe the `gateway.log` rotation, the five-run retention, the redaction pass, and the `promptforge-gateway diagnostics` report. - The boundary test rejects build, dev, and target-specific dependency tables in addition to extra `[dependencies]` entries, so the allowlist covers every way a crate can enter the build. It parses the manifest line by line and adds no TOML parser dependency. - `AGENTS.md` records that `LogEventWriter` is public but `#[doc(hidden)]` because `MakeWriter::Writer` cannot name a private type, and that the test seams `Sink::Null` and `Sink::is_stderr` exist only under `cfg(test)`. Plan: 2026-09-05-1-gateway-logging-cli --- crates/gateway-logging/AGENTS.md | 9 ++-- crates/gateway-logging/tests/it/main.rs | 66 +++++++++++++++++++++++++ crates/gateway/README.md | 2 + guide/promptforge-gateway-guide.md | 8 ++- guide/src/gateway/01-install-and-run.md | 8 ++- 5 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 crates/gateway-logging/tests/it/main.rs diff --git a/crates/gateway-logging/AGENTS.md b/crates/gateway-logging/AGENTS.md index 4e1e3c2c..4c0de16d 100644 --- a/crates/gateway-logging/AGENTS.md +++ b/crates/gateway-logging/AGENTS.md @@ -1,9 +1,12 @@ # gateway-logging -This crate owns the gateway's log pipeline: the bounded priority queue, the `gateway.log` rotation and file sink, and the single worker thread that drains formatted records to disk. +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 public surface is exactly `LogConfig`, `LogRuntime`, `LogWriter`, and the opaque `LogError`. Queue lanes, records, rotation, sinks, mutexes, condition variables, and worker handles stay private. +- `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/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/README.md b/crates/gateway/README.md index 10a1f02e..c7361d52 100644 --- a/crates/gateway/README.md +++ b/crates/gateway/README.md @@ -20,6 +20,8 @@ promptforge-gateway --config gateway.toml --profile main 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. +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 streaming dictation at `/stt`, capability discovery at `GET /stt/capability`, and 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()`). diff --git a/guide/promptforge-gateway-guide.md b/guide/promptforge-gateway-guide.md index 45c0f42a..de0af62a 100644 --- a/guide/promptforge-gateway-guide.md +++ b/guide/promptforge-gateway-guide.md @@ -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 diff --git a/guide/src/gateway/01-install-and-run.md b/guide/src/gateway/01-install-and-run.md index 745eec88..d257a069 100644 --- a/guide/src/gateway/01-install-and-run.md +++ b/guide/src/gateway/01-install-and-run.md @@ -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 From bde3846629eae27f66093bc83b1ffd3c667a87ed Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sat, 5 Sep 2026 20:15:44 -0700 Subject: [PATCH 07/86] Close plan: gateway-logging-cli Plan: 2026-09-05-1-gateway-logging-cli --- vibe/2026-09-05-1-gateway-logging-cli.md | 2 +- vibe/ACTIVE | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) delete mode 100644 vibe/ACTIVE diff --git a/vibe/2026-09-05-1-gateway-logging-cli.md b/vibe/2026-09-05-1-gateway-logging-cli.md index dd30747a..a2a4d8e1 100644 --- a/vibe/2026-09-05-1-gateway-logging-cli.md +++ b/vibe/2026-09-05-1-gateway-logging-cli.md @@ -13,7 +13,7 @@ todos: status: completed - id: logging-verify content: Update rules/docs and complete full verification - status: pending + status: completed - id: baseline-ratchet content: Repair the pre-existing Workshop module ratchets status: completed diff --git a/vibe/ACTIVE b/vibe/ACTIVE deleted file mode 100644 index 86478dfa..00000000 --- a/vibe/ACTIVE +++ /dev/null @@ -1 +0,0 @@ -2026-09-05-1-gateway-logging-cli From 6e5f0ac2b924d38e22d346b05db81cc0667b31f5 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sat, 5 Sep 2026 20:51:53 -0700 Subject: [PATCH 08/86] Pin batch and realtime speech behavior Characterize current batch routing and realtime transcription before the speech subsystem changes. Separate physical-model routing checks from legacy socket cases, and add deterministic coverage for stream policy, generation, origin, ordering, shutdown, and final-model authority. - `crates/gateway-stt/tests/it/main.rs` now separates batch model selection from legacy socket characterization. - `fixture_runtime_with_models` starts caller-selected interim and final fixture models on a dedicated thread, while `TestServer::shutdown` stops the server before blocking runtime shutdown. - `batch_selects_each_loaded_physical_model_by_name` changes one vocabulary token to verify direct routing to each loaded physical model. - `final_model_segments_and_tail_are_authoritative_at_stop` verifies that interim text stays provisional and that the final worker produces committed segments and the remaining tail. - `crates/gateway-stt/tests/it/batch.rs` keeps its physical-model case ignored because it requires `tests/fixtures/`. Other native speech cases remain ignored for the same reason. Design: extends oversized-unit @ crates/gateway-stt/tests/common/mod.rs Design: new flag-parameter @ crates/gateway-stt/tests/common/mod.rs::fixture_runtime deps: bool Design: replaces flag-parameter @ crates/gateway-stt/tests/common/mod.rs::fixture_server deps: bool was: crates/gateway-stt/tests/it/stt.rs::fixture_server Design: new stringly-typed @ crates/gateway-stt/tests/common/mod.rs::multipart_body deps: &[u8],&str boundary: wire Design: new stringly-typed @ crates/gateway-stt/tests/common/mod.rs::transcribe_batch deps: &[f32],&str,SttState boundary: wire Design: replaces oversized-unit @ crates/gateway-stt/tests/it/legacy_stream.rs was: crates/gateway-stt/tests/it/stt.rs Design: new pure-function @ crates/gateway-stt/tests/it/legacy_stream.rs::transcript_words deps: &str Design: new pure-function @ crates/gateway-stt/tests/it/legacy_stream.rs::distinguishing_word deps: &str,&str Deferred: physical-model characterizations remain ignored without whisper fixtures Plan: 2026-09-05-2-generic-realtime-stt --- crates/gateway-stt/tests/common/mod.rs | 213 +++++- crates/gateway-stt/tests/it/batch.rs | 51 ++ .../tests/it/{stt.rs => legacy_stream.rs} | 221 ++++-- crates/gateway-stt/tests/it/main.rs | 3 +- vibe/2026-09-05-2-generic-realtime-stt.md | 646 ++++++++++++++++++ vibe/ACTIVE | 1 + 6 files changed, 1079 insertions(+), 56 deletions(-) create mode 100644 crates/gateway-stt/tests/it/batch.rs rename crates/gateway-stt/tests/it/{stt.rs => legacy_stream.rs} (64%) create mode 100644 vibe/2026-09-05-2-generic-realtime-stt.md create mode 100644 vibe/ACTIVE diff --git a/crates/gateway-stt/tests/common/mod.rs b/crates/gateway-stt/tests/common/mod.rs index f637437b..5531a6c6 100644 --- a/crates/gateway-stt/tests/common/mod.rs +++ b/crates/gateway-stt/tests/common/mod.rs @@ -5,20 +5,116 @@ reason = "test helpers fail by panicking with the invariant named" )] +use std::path::{Path, PathBuf}; use std::time::Duration; +use axum::body::Body; +use axum::extract::{Multipart, State}; +use axum::http::{Request, StatusCode}; +use axum::response::{IntoResponse as _, Response}; +use axum::routing::post; use futures_util::{SinkExt, StreamExt}; use gateway_stt::{SttRuntime, SttState}; use tokio::net::TcpStream; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; +use tower::ServiceExt as _; pub(crate) const RECV_TIMEOUT: Duration = Duration::from_secs(10); +pub(crate) fn fixture_runtime(with_final: bool) -> (SttState, SttRuntime) { + let source = gateway_transcribe::fixtures::require_model(); + fixture_runtime_with_models(&source, with_final.then_some(source.as_path())) +} + +pub(crate) fn fixture_runtime_with_models( + interim_model: &Path, + final_model: Option<&Path>, +) -> (SttState, SttRuntime) { + let interim_model = interim_model.to_path_buf(); + let final_model = final_model.map(Path::to_path_buf); + std::thread::spawn(move || { + fixture_runtime_with_models_on_dedicated_thread(&interim_model, final_model.as_deref()) + }) + .join() + .expect("fixture runtime startup thread succeeds") +} + +fn fixture_runtime_with_models_on_dedicated_thread( + interim_model: &Path, + final_model: Option<&Path>, +) -> (SttState, SttRuntime) { + 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!( + "[[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\ + [workshop.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 state = SttState::default(); + let runtime = SttRuntime::start(&config, state.clone(), None).expect("fixture engine loads"); + (state, runtime) +} + +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) fn fixture_server(with_final: bool) -> TestServer { + let (state, runtime) = fixture_runtime(with_final); + TestServer::spawn_with(state, Some(runtime)) +} + pub(crate) struct TestServer { url: String, task: tokio::task::JoinHandle<()>, - _runtime: Option, + runtime: Option, } impl TestServer { @@ -46,7 +142,7 @@ impl TestServer { Self { url: format!("http://{address}"), task, - _runtime: runtime, + runtime, } } @@ -57,6 +153,16 @@ impl TestServer { path ) } + + pub(crate) async fn shutdown(mut self) { + self.task.abort(); + let _ = (&mut self.task).await; + if let Some(runtime) = self.runtime.take() { + tokio::task::spawn_blocking(move || runtime.shutdown()) + .await + .expect("fixture runtime shutdown task succeeds"); + } + } } impl Drop for TestServer { @@ -65,6 +171,109 @@ impl Drop for TestServer { } } +pub(crate) async fn send_pcm(socket: &mut JsonSocket, frames: usize) { + socket.send_binary(vec![0u8; frames * 4]).await; +} + +pub(crate) 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; + } +} + +pub(crate) async fn send_samples_once(socket: &mut JsonSocket, samples: &[f32]) { + let mut bytes = Vec::with_capacity(samples.len() * 4); + for sample in samples { + bytes.extend_from_slice(&sample.to_le_bytes()); + } + socket.send_binary(bytes).await; +} + +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() +} + +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) +} + +async fn batch_endpoint(State(state): State, multipart: Multipart) -> Response { + match gateway_stt::transcribe(&state, multipart).await { + Ok(response) => 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(), + } +} + +pub(crate) async fn transcribe_batch( + state: SttState, + model: &str, + samples: &[f32], +) -> (StatusCode, serde_json::Value) { + let (boundary, body) = multipart_body(&wav_f32(samples), model); + let response = axum::Router::new() + .route("/v1/audio/transcriptions", post(batch_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("batch request builds"), + ) + .await + .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) +} + pub(crate) struct JsonSocket { socket: WebSocketStream>, } diff --git a/crates/gateway-stt/tests/it/batch.rs b/crates/gateway-stt/tests/it/batch.rs new file mode 100644 index 00000000..97531e82 --- /dev/null +++ b/crates/gateway-stt/tests/it/batch.rs @@ -0,0 +1,51 @@ +//! Characterization tests for physical-model batch transcription. + +use axum::http::StatusCode; +use gateway_transcribe::fixtures::jfk_samples; + +use crate::common::{copy_model_replacing_token, fixture_runtime_with_models, transcribe_batch}; + +#[tokio::test] +#[ignore = "requires whisper test fixtures (tests/fixtures/)"] +async fn batch_selects_each_loaded_physical_model_by_name() { + let interim_model = gateway_transcribe::fixtures::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 (state, runtime) = fixture_runtime_with_models(&interim_model, Some(final_model.as_path())); + let samples = jfk_samples(); + + let (interim_status, interim_response) = + transcribe_batch(state.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(state.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:?}" + ); + + runtime.shutdown(); +} diff --git a/crates/gateway-stt/tests/it/stt.rs b/crates/gateway-stt/tests/it/legacy_stream.rs similarity index 64% rename from crates/gateway-stt/tests/it/stt.rs rename to crates/gateway-stt/tests/it/legacy_stream.rs index 23194d9e..a0bc5376 100644 --- a/crates/gateway-stt/tests/it/stt.rs +++ b/crates/gateway-stt/tests/it/legacy_stream.rs @@ -8,60 +8,121 @@ 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 gateway_transcribe::fixtures::jfk_samples; +use gateway_transcribe::{MIN_WINDOW_SAMPLES, SAMPLE_RATE, Segmenter}; use serde_json::json; use tokio_tungstenite::tungstenite; use tokio_tungstenite::tungstenite::client::IntoClientRequest; -use crate::common::{JsonSocket, TestServer}; +use crate::common::{ + JsonSocket, TestServer, copy_model_replacing_token, fixture_runtime, + fixture_runtime_with_models, fixture_server, send_pcm, send_samples, send_samples_once, + transcribe_batch, +}; -async fn send_pcm(socket: &mut JsonSocket, frames: usize) { - socket.send_binary(vec![0u8; frames * 4]).await; +#[test] +fn legacy_stream_policy_constants_stay_pinned() { + let capture = gateway_config::WorkshopSttConfig::default(); + assert_eq!(SAMPLE_RATE, 16_000, "wire PCM stays at 16 kHz"); + assert_eq!( + MIN_WINDOW_SAMPLES, + SAMPLE_RATE / 2, + "interim decoding still requires half a second" + ); + assert_eq!( + capture.window_seconds(), + 15, + "the default interim window stays fifteen seconds" + ); + assert_eq!( + capture.interval_ms(), + 500, + "the default interim cadence stays 500 ms" + ); } -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 transcript_words(text: &str) -> Vec { + text.split_whitespace() + .map(|word| { + word.trim_matches(|character: char| !character.is_ascii_alphanumeric()) + .to_ascii_lowercase() + }) + .filter(|word| word.len() >= 4) + .collect() } -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)) +fn distinguishing_word(text: &str, other: &str) -> String { + let other = transcript_words(other); + transcript_words(text) + .into_iter() + .find(|word| !other.contains(word)) + .expect("the two speech segments have distinguishable words") +} + +#[tokio::test] +#[ignore = "requires whisper test fixtures (tests/fixtures/)"] +async fn closed_segments_are_reported_in_input_order() { + let speech = jfk_samples(); + let third = speech.len() / 3; + let mut samples = speech[..third].to_vec(); + samples.extend(vec![0.0; 3 * SAMPLE_RATE]); + samples.extend_from_slice(&speech[2 * third..]); + samples.extend(vec![0.0; 3 * SAMPLE_RATE]); + let mut segmenter = Segmenter::new(); + let mut ranges = Vec::new(); + while let Some(range) = segmenter.poll(&samples) { + ranges.push(range); + } + assert_eq!( + ranges.len(), + 2, + "the native fixture halves form two closed speech segments" + ); + + let (state, runtime) = fixture_runtime(true); + let (first_status, first_response) = + transcribe_batch(state.clone(), "speech-final", &samples[ranges[0].clone()]).await; + let (second_status, second_response) = + transcribe_batch(state.clone(), "speech-final", &samples[ranges[1].clone()]).await; + assert_eq!(first_status, axum::http::StatusCode::OK); + assert_eq!(second_status, axum::http::StatusCode::OK); + let first = first_response["text"] + .as_str() + .expect("first segment transcript is a string"); + let second = second_response["text"] + .as_str() + .expect("second segment transcript is a string"); + let first_marker = distinguishing_word(first, second); + let second_marker = distinguishing_word(second, first); + + let server = TestServer::spawn_with(state, Some(runtime)); + 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_once(&mut socket, &samples).await; + socket.send_text("stop").await; + let reply = socket + .recv_until(Duration::from_secs(240), |frame| frame["type"] == "final") + .await; + let final_text = reply["text"] + .as_str() + .expect("streaming final transcript is a string"); + let final_words = transcript_words(final_text); + let first_position = final_words + .iter() + .position(|word| word == &first_marker) + .expect("the streaming final contains the first segment marker"); + let second_position = final_words + .iter() + .position(|word| word == &second_marker) + .expect("the streaming final contains the second segment marker"); + assert!( + first_position < second_position, + "the /stt final preserves submitted segment order: {first_marker:?} before \ + {second_marker:?} in {final_text:?}" + ); + socket.close().await; + server.shutdown().await; } #[tokio::test] @@ -300,13 +361,13 @@ async fn silence_produces_no_interims_and_an_empty_final() { socket.close().await; } -async fn wait_for_committed(socket: &mut JsonSocket) -> String { +async fn wait_for_committed(socket: &mut JsonSocket, expected_word: &str) -> 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")) + .is_some_and(|text| text.to_lowercase().contains(expected_word)) }) .await["committed"] .as_str() @@ -316,15 +377,40 @@ async fn wait_for_committed(socket: &mut JsonSocket) -> String { #[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); +async fn final_model_segments_and_tail_are_authoritative_at_stop() { + let interim_model = gateway_transcribe::fixtures::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 (state, runtime) = fixture_runtime_with_models(&interim_model, Some(final_model.as_path())); + let server = TestServer::spawn_with(state, Some(runtime)); 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; + 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!( + !interim["tentative"] + .as_str() + .expect("interim tentative text is a string") + .to_lowercase() + .contains("kingdom"), + "the provisional transcript comes from the unmodified interim worker" + ); send_pcm(&mut socket, 3 * 16_000).await; - let committed = wait_for_committed(&mut socket).await; + let committed = wait_for_committed(&mut socket, "kingdom").await; + assert!( + !committed.to_lowercase().contains("country"), + "the closed segment comes from the vocabulary-distinguished final worker: {committed:?}" + ); send_samples(&mut socket, &samples).await; socket.send_text("stop").await; let reply = socket @@ -339,10 +425,39 @@ async fn final_frame_is_the_committed_prefix_plus_the_tail() { .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:?}" + tail.to_lowercase().contains("kingdom") && !tail.to_lowercase().contains("country"), + "the tail comes from the vocabulary-distinguished final worker: {text:?}" ); socket.close().await; + server.shutdown().await; +} + +#[tokio::test] +#[ignore = "requires whisper test fixtures (tests/fixtures/)"] +async fn a_disconnected_client_does_not_break_the_next_final_take() { + let server = fixture_server(true); + let mut abandoned = JsonSocket::connect(&server.ws_url("/stt")).await; + abandoned.send_text("start").await; + assert_eq!(abandoned.recv_json().await["type"], "stream"); + send_samples(&mut abandoned, &jfk_samples()).await; + send_pcm(&mut abandoned, 3 * SAMPLE_RATE).await; + abandoned.close().await; + + let mut survivor = JsonSocket::connect(&server.ws_url("/stt")).await; + survivor.send_text("start").await; + assert_eq!(survivor.recv_json().await["type"], "stream"); + send_samples(&mut survivor, &jfk_samples()).await; + survivor.send_text("stop").await; + let reply = survivor + .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"), + "a dropped completion receiver does not poison the shared final worker: {text:?}" + ); + survivor.close().await; + server.shutdown().await; } #[tokio::test] @@ -354,7 +469,7 @@ async fn stop_at_a_segment_boundary_returns_the_committed_prefix() { 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; + let committed = wait_for_committed(&mut socket, "country").await; socket.send_text("stop").await; let reply = socket .recv_until(Duration::from_secs(180), |frame| frame["type"] == "final") diff --git a/crates/gateway-stt/tests/it/main.rs b/crates/gateway-stt/tests/it/main.rs index 2f30d3cf..e13791bc 100644 --- a/crates/gateway-stt/tests/it/main.rs +++ b/crates/gateway-stt/tests/it/main.rs @@ -3,4 +3,5 @@ #[path = "../common/mod.rs"] mod common; -mod stt; +mod batch; +mod legacy_stream; 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..715ef544 --- /dev/null +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -0,0 +1,646 @@ +--- +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 +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. +- 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. + - 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 internal module graphs for all four STT crates 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. +- 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 20: `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 21 through 25, adds only `workshop-server -> shared-loopback`. Final Phase C, Step 26 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 the unfiltered architecture harness. + +### Step 1: Characterize current speech behavior - c6198001 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- Artifacts: create `crates/gateway-stt/tests/it/architecture.rs`, register it in `tests/it/main.rs`, and create `module-ceilings.toml` in all four STT crates. +- Scope: enforce the stated temporary and final workspace-edge allowlists, unsafe leaf, no cycles, current ceilings, and public budgets; temporary exceptions name their removal step. +- Focused test commands: + - `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 + +- 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`: `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 + +- 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`: `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 + +- 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`: `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 + +- 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` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo deny check` + - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt audio` + - `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 + +- 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`: `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: Own sessions and uncommitted input + +- 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`: `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 14: Finalize committed items independently + +- 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`: `cargo test -p gateway-stt --test it architecture` +- Consumes and gates: consumes Step 13; complete item ownership gates facade replacement and generation quiescence. + +### Step 15: Replace runtime and route APIs atomically + +- 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`: `cargo test -p gateway-stt --test it architecture` +- Consumes and gates: consumes Steps 10 and 14; every current reverse consumer compiles and tests in this API-changing commit. + +### Step 16: Quiesce generations with explicit ownership + +- 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`: `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 17: Make profile replacement transactional + +- 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`: `cargo test -p gateway-stt --test it architecture` +- Consumes and gates: consumes Step 16; cancellation-at-every-await and rollback outcomes gate route mounting. + +### Step 18: Separate origin predicates + +- 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 19: Integrate generic speech facts + +- 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`: `cargo test -p gateway-stt --test it architecture` +- Consumes and gates: consumes Step 15 facade and Step 17 lifecycle; status correctness gates route publication. + +### Step 20: Mount the additive Gateway route + +- 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`: `cargo test -p gateway-stt --test it architecture` +- Consumes and gates: consumes Steps 12 through 19; the independent Gateway fixture path gates Workshop relay work. + +### Step 21: Add the Workshop relay beside legacy + +- 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 18 Workshop predicate and Step 20 public fixtures, but adds no dependency on Gateway or gateway-stt. + +### Step 22: Prove the actual worklet bytes + +- 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 21 additive relay; byte parity gates browser migration. + +### Step 23: Migrate Workshop browser speech + +- 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, 21, and 22; browser acceptance gates independent full-path automation. + +### Step 24: Prove both fixture-driven halves + +- 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 20 through 23; both independent halves must pass before packaging. + +### Step 25: Pass installed Windows microphone acceptance + +- 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` steps `Build and stage the gateway sidecar`, `Build the app`, and `Install and check (Windows)`, then record installed-package microphone revision, completion, second take, clear, cancellation, and recoverable permission or device failure with binary hashes and timestamps. +- 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`: `if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY)) { throw 'TAURI_SIGNING_PRIVATE_KEY is required by the release workflow' }; if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD)) { throw 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD is required by the release workflow' }; $env:RUSTUP_TOOLCHAIN='stable'; cargo tauri build --bundles nsis` + - `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 24; the operator must exercise the installed application and record passing evidence, which alone gates legacy removal. + +### Step 26: Remove legacy seams and tests + +- 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 Step 3, 20, 21, 23, or 24 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`: `cargo test -p gateway-stt --test it architecture` +- Consumes and gates: consumes the passing installed-package record; failed replacement coverage blocks deletion. + +### Step 27: Finalize architecture and documentation + +- 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`: `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 26 final topology; final verification starts only with zero temporary exceptions. + +### Step 28: Run every release gate and repeat acceptance + +- Artifacts: append command results, hashes, ratchet counts, native equivalence, generated-doc cleanliness, and repeated installed-microphone evidence to `design/generic-realtime-stt-acceptance.md`; change no implementation. +- Scope: run every exit criterion independently under PowerShell 5.1 and stop on any failure. +- 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`: `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`: `if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY)) { throw 'TAURI_SIGNING_PRIVATE_KEY is required by the release workflow' }; if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD)) { throw 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD is required by the release workflow' }; $env:RUSTUP_TOOLCHAIN='stable'; cargo tauri build --bundles nsis` + - `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 27, then repeats the Step 25 installed-package microphone scenarios. 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, or completed Workshop baseline-ratchet work. VAD tuning, alignment, 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/ACTIVE b/vibe/ACTIVE new file mode 100644 index 00000000..42299ab6 --- /dev/null +++ b/vibe/ACTIVE @@ -0,0 +1 @@ +2026-09-05-2-generic-realtime-stt From bcc6582a9ca56491d2a98b1afbc43461d7984e65 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sat, 5 Sep 2026 21:56:57 -0700 Subject: [PATCH 09/86] Pin native Whisper runtime behavior Add an ignored native integration target that loads the packaged runtime and exact model fixture. It fixes interim, final, transcript-conditioning, glossary-prompt, silence-gating, and cleanup behavior so the upcoming engine split can be checked against one baseline. - `packaged_runtime_preserves_native_transcription_contract` copies the exact tiny model into a temporary directory, loads the packaged library, and configures the same model for interim and final decoding. - `JFK_TRANSCRIPT` anchors assertions for the full interim transcription, unprompted and transcript-conditioned final output, glossary bias, silence gating, and conditioning divergence. - `std::fs::remove_file` verifies that dropping both engines releases the copied model. - `#[ignore = "requires whisper test fixtures (tests/fixtures/)"]` keeps native characterization outside default test runs because it requires packaged runtime fixtures. Design: new oversized-unit @ crates/gateway-transcribe/tests/native_whisper.rs::packaged_runtime_preserves_native_transcription_contract Plan: 2026-09-05-2-generic-realtime-stt --- .../tests/native_whisper.rs | 108 ++++++++++++++++++ vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 crates/gateway-transcribe/tests/native_whisper.rs diff --git a/crates/gateway-transcribe/tests/native_whisper.rs b/crates/gateway-transcribe/tests/native_whisper.rs new file mode 100644 index 00000000..5a672bbe --- /dev/null +++ b/crates/gateway-transcribe/tests/native_whisper.rs @@ -0,0 +1,108 @@ +//! Native characterization of the packaged Whisper runtime contract. + +use std::time::Duration; + +use gateway_transcribe::{EngineConfig, SttEngine, fixtures}; + +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; + +#[tokio::test] +#[ignore = "requires whisper test fixtures (tests/fixtures/)"] +async fn packaged_runtime_preserves_native_transcription_contract() { + let temp = tempfile::tempdir().expect("temporary packaged-runtime directory"); + let library = fixtures::require_library(); + let model = temp.path().join("ggml-tiny.en.bin"); + std::fs::copy(fixtures::require_model(), &model).expect("copy the exact tiny model fixture"); + let samples = fixtures::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 = SttEngine::new(&EngineConfig { + library: library.clone(), + interim_model: model.clone(), + final_model: Some(model.clone()), + vocabulary: Vec::new(), + window_seconds: 12, + interval_ms: 500, + }) + .expect("packaged runtime and model load"); + + let interim = unprompted + .transcribe(samples.clone()) + .await + .expect("interim decode succeeds"); + assert_eq!(interim, JFK_TRANSCRIPT, "interim decode policy stays fixed"); + + let unprompted_clip = unprompted + .transcribe_final(prompt_sensitive_clip.clone()) + .await + .expect("a final model is configured") + .expect("unprompted final decode succeeds"); + assert_eq!( + unprompted_clip, UNPROMPTED_CLIP_TRANSCRIPT, + "the prompt-sensitive clip has a fixed unprompted control" + ); + + let (segment_tx, segment_rx) = std::sync::mpsc::channel(); + unprompted.final_reset(segment_tx); + unprompted.final_submit(conditioning_clip); + let conditioned_clip = unprompted + .final_finish(prompt_sensitive_clip.clone()) + .await + .expect("a final model is configured") + .expect("transcript-conditioned final decode succeeds"); + let conditioning_transcript = segment_rx + .recv_timeout(Duration::from_secs(1)) + .expect("conditioning segment reports its transcript"); + assert_eq!( + conditioning_transcript, CONDITIONING_TRANSCRIPT, + "the accumulated transcript that conditions the tail stays fixed" + ); + assert_eq!( + conditioned_clip, CONDITIONED_CLIP_TRANSCRIPT, + "the accumulated transcript changes the prompt-sensitive tail" + ); + assert_ne!( + conditioned_clip, unprompted_clip, + "removing accumulated-transcript conditioning must fail this target" + ); + + let glossary_prompted = SttEngine::new(&EngineConfig { + library, + interim_model: model.clone(), + final_model: Some(model.clone()), + vocabulary: vec!["one tree".to_string()], + window_seconds: 12, + interval_ms: 500, + }) + .expect("glossary-prompted engine loads"); + let (segment_tx, segment_rx) = std::sync::mpsc::channel(); + glossary_prompted.final_reset(segment_tx); + glossary_prompted.final_submit(prompt_sensitive_clip); + let silent_tail = glossary_prompted + .final_finish(vec![0.0; 16_000]) + .await + .expect("a final model is configured") + .expect("the silent tail drains the glossary-conditioned segment"); + let glossary_clip = segment_rx + .recv_timeout(Duration::from_secs(1)) + .expect("glossary-conditioned segment reports its transcript"); + assert!(silent_tail.is_empty(), "silence remains gated"); + assert_eq!( + glossary_clip, GLOSSARY_CLIP_TRANSCRIPT, + "the glossary changes the prompt-sensitive segment" + ); + assert_ne!( + glossary_clip, unprompted_clip, + "removing glossary conditioning must fail this target" + ); + + drop(glossary_prompted); + drop(unprompted); + std::fs::remove_file(model).expect("dropping the engine releases the model"); +} diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 715ef544..737e8871 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -338,7 +338,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### Step 2: Pin the pre-rename native target - f7afccf6 - 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. From 655dc16066aa3c09b85e1a52f66c44d0efd1afda Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sat, 5 Sep 2026 22:13:22 -0700 Subject: [PATCH 10/86] Freeze the realtime transcription wire contract Add canonical client, server, session, error, and sequence fixtures for realtime transcription. Validate one shared fixture set in Rust and the Workshop UI to pin strict fields, event ordering, capacity errors, item isolation, and hypothesis semantics before the subsystem changes. - `realtime-wire-fixtures.mjs` consumes the Gateway-owned fixtures directly, which makes Rust and Workshop share one canonical corpus. - `canonical_realtime_events_are_complete_strict_and_round_trip` enforces exact case sets, strict field sets, identifier separation, session defaults, and hypothesis composition. - `canonical_realtime_sequences_cover_valid_and_invalid_contract_paths` validates event order, error correlation, minimum audio, and recovery metadata across all declared sequences. - `crates/gateway-stt/tests/it/realtime_fixtures.rs` does not call production realtime parsers or session handlers, so these tests pin fixture consistency rather than implementation conformance. Design: new oversized-unit @ crates/gateway-stt/tests/it/realtime_fixtures.rs::assert_server_event_fields deps: &Value,&str Design: new oversized-unit @ crates/gateway-stt/tests/it/realtime_fixtures.rs::canonical_realtime_events_are_complete_strict_and_round_trip Design: new oversized-unit @ crates/gateway-stt/tests/it/realtime_fixtures.rs::canonical_realtime_sequences_cover_valid_and_invalid_contract_paths Violates: A2 - not determinable from diff Violates: A96 - not determinable from diff Deferred: batch transcription characterization is absent Deferred: native two-model characterization is absent Plan: 2026-09-05-2-generic-realtime-stt --- .../fixtures/realtime/client-events.json | 37 + .../fixtures/realtime/effective-sessions.json | 44 ++ .../fixtures/realtime/invalid-sequences.json | 224 ++++++ .../fixtures/realtime/server-events.json | 153 ++++ .../fixtures/realtime/valid-sequences.json | 198 ++++++ crates/gateway-stt/tests/it/main.rs | 1 + .../gateway-stt/tests/it/realtime_fixtures.rs | 671 ++++++++++++++++++ .../ui/test/realtime-wire-fixtures.mjs | 399 +++++++++++ vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- vibe/archdoc-next.md | 2 + 10 files changed, 1730 insertions(+), 1 deletion(-) create mode 100644 crates/gateway-stt/tests/fixtures/realtime/client-events.json create mode 100644 crates/gateway-stt/tests/fixtures/realtime/effective-sessions.json create mode 100644 crates/gateway-stt/tests/fixtures/realtime/invalid-sequences.json create mode 100644 crates/gateway-stt/tests/fixtures/realtime/server-events.json create mode 100644 crates/gateway-stt/tests/fixtures/realtime/valid-sequences.json create mode 100644 crates/gateway-stt/tests/it/realtime_fixtures.rs create mode 100644 crates/workshop-server/ui/test/realtime-wire-fixtures.mjs 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..aef910ba --- /dev/null +++ b/crates/gateway-stt/tests/fixtures/realtime/valid-sequences.json @@ -0,0 +1,198 @@ +{ + "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" + ] + }, + "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/main.rs b/crates/gateway-stt/tests/it/main.rs index e13791bc..5b5bc94a 100644 --- a/crates/gateway-stt/tests/it/main.rs +++ b/crates/gateway-stt/tests/it/main.rs @@ -5,3 +5,4 @@ mod common; mod batch; mod legacy_stream; +mod realtime_fixtures; 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..0d5832e9 --- /dev/null +++ b/crates/gateway-stt/tests/it/realtime_fixtures.rs @@ -0,0 +1,671 @@ +#![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", + "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/workshop-server/ui/test/realtime-wire-fixtures.mjs b/crates/workshop-server/ui/test/realtime-wire-fixtures.mjs new file mode 100644 index 00000000..628be0a3 --- /dev/null +++ b/crates/workshop-server/ui/test/realtime-wire-fixtures.mjs @@ -0,0 +1,399 @@ +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"; + +const testDir = path.dirname(fileURLToPath(import.meta.url)); +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", + "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 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("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`); + } + } + 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/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 737e8871..9e13991a 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -346,7 +346,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### Step 3: Freeze canonical Realtime fixtures - d743690b - 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index ad6953d8..bd4286d3 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -126,3 +126,5 @@ - [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 From 6b7ec5ffa60df0ba5b07a1b6dda35c9764620f2c Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sat, 5 Sep 2026 22:26:27 -0700 Subject: [PATCH 11/86] Rename the transcription crate as the STT engine Give the backend-neutral engine its intended role-specific identity. Update workspace metadata, runtime references, tests, fixture exclusions, and documentation while preserving the engine implementation. - `crates/gateway-stt-engine` changes the package, path, and Rust import identity. - `crates/gateway-stt-engine/src/engine.rs` and the other engine source files move with 100 percent similarity. - `crates/gateway-stt-engine/tests/native_whisper.rs` changes only its import path. The commit adds no test assertions or compatibility crate. Design: new shotgun-surgery @ crates/gateway-stt-engine/Cargo.toml Violates: A2 - not determinable from diff Violates: A96 - not determinable from diff Plan: 2026-09-05-2-generic-realtime-stt --- .gitignore | 2 +- Cargo.lock | 6 +++--- Cargo.toml | 2 +- .../{gateway-transcribe => gateway-stt-engine}/AGENTS.md | 2 +- .../{gateway-transcribe => gateway-stt-engine}/Cargo.toml | 4 ++-- .../src/engine.rs | 0 .../src/error.rs | 0 .../src/final_pass.rs | 0 .../{gateway-transcribe => gateway-stt-engine}/src/lib.rs | 0 .../src/prompt.rs | 0 .../src/segment.rs | 0 .../src/slot.rs | 0 .../src/worker.rs | 0 .../tests/native_whisper.rs | 2 +- crates/gateway-stt/AGENTS.md | 4 ++-- crates/gateway-stt/Cargo.toml | 4 ++-- crates/gateway-stt/src/api.rs | 8 ++++---- crates/gateway-stt/src/lib.rs | 2 +- crates/gateway-stt/src/runtime.rs | 6 +++--- crates/gateway-stt/src/stt.rs | 2 +- crates/gateway-stt/tests/common/mod.rs | 2 +- crates/gateway-stt/tests/it/batch.rs | 4 ++-- crates/gateway-stt/tests/it/legacy_stream.rs | 6 +++--- tools/document.md | 2 +- vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- 25 files changed, 30 insertions(+), 30 deletions(-) rename crates/{gateway-transcribe => gateway-stt-engine}/AGENTS.md (97%) rename crates/{gateway-transcribe => gateway-stt-engine}/Cargo.toml (92%) rename crates/{gateway-transcribe => gateway-stt-engine}/src/engine.rs (100%) rename crates/{gateway-transcribe => gateway-stt-engine}/src/error.rs (100%) rename crates/{gateway-transcribe => gateway-stt-engine}/src/final_pass.rs (100%) rename crates/{gateway-transcribe => gateway-stt-engine}/src/lib.rs (100%) rename crates/{gateway-transcribe => gateway-stt-engine}/src/prompt.rs (100%) rename crates/{gateway-transcribe => gateway-stt-engine}/src/segment.rs (100%) rename crates/{gateway-transcribe => gateway-stt-engine}/src/slot.rs (100%) rename crates/{gateway-transcribe => gateway-stt-engine}/src/worker.rs (100%) rename crates/{gateway-transcribe => gateway-stt-engine}/tests/native_whisper.rs (98%) diff --git a/.gitignore b/.gitignore index eae913f6..278711ad 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-engine/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. diff --git a/Cargo.lock b/Cargo.lock index 55c04dca..5d282c04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2000,7 +2000,7 @@ dependencies = [ "gateway-config", "gateway-local", "gateway-stt", - "gateway-transcribe", + "gateway-stt-engine", "hound", "serde", "serde_json", @@ -2016,10 +2016,10 @@ dependencies = [ ] [[package]] -name = "gateway-transcribe" +name = "gateway-stt-engine" version = "0.2.0" dependencies = [ - "gateway-transcribe", + "gateway-stt-engine", "gateway-whisper-ffi", "hound", "shared-progress", diff --git a/Cargo.toml b/Cargo.toml index 8d6478de..61fc6a24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,7 +39,7 @@ 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" } 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/crates/gateway-transcribe/AGENTS.md b/crates/gateway-stt-engine/AGENTS.md similarity index 97% rename from crates/gateway-transcribe/AGENTS.md rename to crates/gateway-stt-engine/AGENTS.md index c7e3d169..8e2f26e5 100644 --- a/crates/gateway-transcribe/AGENTS.md +++ b/crates/gateway-stt-engine/AGENTS.md @@ -1,4 +1,4 @@ -# gateway-transcribe +# gateway-stt-engine 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. diff --git a/crates/gateway-transcribe/Cargo.toml b/crates/gateway-stt-engine/Cargo.toml similarity index 92% rename from crates/gateway-transcribe/Cargo.toml rename to crates/gateway-stt-engine/Cargo.toml index c422a61f..9f6bc596 100644 --- a/crates/gateway-transcribe/Cargo.toml +++ b/crates/gateway-stt-engine/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "gateway-transcribe" +name = "gateway-stt-engine" version.workspace = true edition.workspace = true rust-version.workspace = true @@ -28,7 +28,7 @@ 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"] } +gateway-stt-engine = { path = ".", features = ["test-fixtures"] } tempfile.workspace = true tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/gateway-transcribe/src/engine.rs b/crates/gateway-stt-engine/src/engine.rs similarity index 100% rename from crates/gateway-transcribe/src/engine.rs rename to crates/gateway-stt-engine/src/engine.rs diff --git a/crates/gateway-transcribe/src/error.rs b/crates/gateway-stt-engine/src/error.rs similarity index 100% rename from crates/gateway-transcribe/src/error.rs rename to crates/gateway-stt-engine/src/error.rs diff --git a/crates/gateway-transcribe/src/final_pass.rs b/crates/gateway-stt-engine/src/final_pass.rs similarity index 100% rename from crates/gateway-transcribe/src/final_pass.rs rename to crates/gateway-stt-engine/src/final_pass.rs diff --git a/crates/gateway-transcribe/src/lib.rs b/crates/gateway-stt-engine/src/lib.rs similarity index 100% rename from crates/gateway-transcribe/src/lib.rs rename to crates/gateway-stt-engine/src/lib.rs diff --git a/crates/gateway-transcribe/src/prompt.rs b/crates/gateway-stt-engine/src/prompt.rs similarity index 100% rename from crates/gateway-transcribe/src/prompt.rs rename to crates/gateway-stt-engine/src/prompt.rs diff --git a/crates/gateway-transcribe/src/segment.rs b/crates/gateway-stt-engine/src/segment.rs similarity index 100% rename from crates/gateway-transcribe/src/segment.rs rename to crates/gateway-stt-engine/src/segment.rs diff --git a/crates/gateway-transcribe/src/slot.rs b/crates/gateway-stt-engine/src/slot.rs similarity index 100% rename from crates/gateway-transcribe/src/slot.rs rename to crates/gateway-stt-engine/src/slot.rs diff --git a/crates/gateway-transcribe/src/worker.rs b/crates/gateway-stt-engine/src/worker.rs similarity index 100% rename from crates/gateway-transcribe/src/worker.rs rename to crates/gateway-stt-engine/src/worker.rs diff --git a/crates/gateway-transcribe/tests/native_whisper.rs b/crates/gateway-stt-engine/tests/native_whisper.rs similarity index 98% rename from crates/gateway-transcribe/tests/native_whisper.rs rename to crates/gateway-stt-engine/tests/native_whisper.rs index 5a672bbe..b6eb1dce 100644 --- a/crates/gateway-transcribe/tests/native_whisper.rs +++ b/crates/gateway-stt-engine/tests/native_whisper.rs @@ -2,7 +2,7 @@ use std::time::Duration; -use gateway_transcribe::{EngineConfig, SttEngine, fixtures}; +use gateway_stt_engine::{EngineConfig, SttEngine, fixtures}; 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."; diff --git a/crates/gateway-stt/AGENTS.md b/crates/gateway-stt/AGENTS.md index fc971f8b..47b9e5bb 100644 --- a/crates/gateway-stt/AGENTS.md +++ b/crates/gateway-stt/AGENTS.md @@ -2,7 +2,7 @@ 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. -- Runtime ownership only. Whisper inference primitives stay in `gateway-transcribe`; artifact download and verification stay in `gateway-local::artifacts::ArtifactStore`. +- Runtime ownership only. Whisper inference primitives stay in `gateway-stt-engine`; 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. +- The whisper.cpp runtime is provisioned through `ArtifactStore` and handed to `gateway-stt-engine` 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. diff --git a/crates/gateway-stt/Cargo.toml b/crates/gateway-stt/Cargo.toml index 89737f17..b7668510 100644 --- a/crates/gateway-stt/Cargo.toml +++ b/crates/gateway-stt/Cargo.toml @@ -16,7 +16,7 @@ hound.workspace = true gateway-config.workspace = true gateway-local.workspace = true shared-progress.workspace = true -gateway-transcribe.workspace = true +gateway-stt-engine.workspace = true workshop-server.workspace = true serde.workspace = true serde_json.workspace = true @@ -26,7 +26,7 @@ tracing.workspace = true [features] default = [] -test-fixtures = ["gateway-transcribe/test-fixtures"] +test-fixtures = ["gateway-stt-engine/test-fixtures"] [dev-dependencies] gateway-stt = { path = ".", features = ["test-fixtures"] } diff --git a/crates/gateway-stt/src/api.rs b/crates/gateway-stt/src/api.rs index e5684de5..abbfe850 100644 --- a/crates/gateway-stt/src/api.rs +++ b/crates/gateway-stt/src/api.rs @@ -4,7 +4,7 @@ use std::io::Cursor; use axum::extract::Multipart; use axum::response::{IntoResponse, Response}; -use gateway_transcribe::SAMPLE_RATE; +use gateway_stt_engine::SAMPLE_RATE; use serde::Serialize; use crate::runtime::{LoadedModelRole, SttState}; @@ -326,7 +326,7 @@ pub enum TranscriptionError { /// Whisper rejected the audio. #[non_exhaustive] #[error("transcribe audio")] - Inference(#[source] gateway_transcribe::TranscribeError), + Inference(#[source] gateway_stt_engine::TranscribeError), } impl TranscriptionError { @@ -564,7 +564,7 @@ mod tests { #[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() + let source = gateway_stt_engine::fixtures::require_model() .display() .to_string() .replace('\\', "/"); @@ -584,7 +584,7 @@ mod tests { .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 samples = gateway_stt_engine::fixtures::jfk_samples(); let (boundary, body) = multipart_body( &wav_f32(&samples), &[ diff --git a/crates/gateway-stt/src/lib.rs b/crates/gateway-stt/src/lib.rs index 2adb6b0b..011e62f0 100644 --- a/crates/gateway-stt/src/lib.rs +++ b/crates/gateway-stt/src/lib.rs @@ -2,7 +2,7 @@ //! //! [`SttRuntime`] provisions the selected profile's speech models through //! [`ArtifactStore`](gateway_local::artifacts::ArtifactStore), -//! loads [`SttEngine`](gateway_transcribe::SttEngine), and unloads it on +//! loads [`SttEngine`](gateway_stt_engine::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. diff --git a/crates/gateway-stt/src/runtime.rs b/crates/gateway-stt/src/runtime.rs index 83968680..c42629fc 100644 --- a/crates/gateway-stt/src/runtime.rs +++ b/crates/gateway-stt/src/runtime.rs @@ -5,7 +5,7 @@ use std::sync::{Arc, PoisonError, RwLock}; use gateway_config::{Config, SttRole, WorkshopSttConfig}; use gateway_local::artifacts::ArtifactStore; -use gateway_transcribe::{EngineConfig, SttEngine, SttSlot}; +use gateway_stt_engine::{EngineConfig, SttEngine, SttSlot}; use shared_progress::ProgressHandle; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -282,7 +282,7 @@ pub enum SttRuntimeError { /// The provisioned whisper pair could not be loaded. #[non_exhaustive] #[error("load STT engine")] - Engine(#[source] gateway_transcribe::TranscribeError), + Engine(#[source] gateway_stt_engine::TranscribeError), } #[cfg(test)] @@ -369,7 +369,7 @@ mod tests { #[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() + let source = gateway_stt_engine::fixtures::require_model() .display() .to_string() .replace('\\', "/"); diff --git a/crates/gateway-stt/src/stt.rs b/crates/gateway-stt/src/stt.rs index 9bcaadb6..05f99fac 100644 --- a/crates/gateway-stt/src/stt.rs +++ b/crates/gateway-stt/src/stt.rs @@ -10,7 +10,7 @@ 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 gateway_stt_engine::{MIN_WINDOW_SAMPLES, SAMPLE_RATE, Segmenter, SttEngine, is_silence, tail}; use serde::Serialize; use tokio::sync::{mpsc, watch}; use workshop_server::{Activity, Push}; diff --git a/crates/gateway-stt/tests/common/mod.rs b/crates/gateway-stt/tests/common/mod.rs index 5531a6c6..98e25bd2 100644 --- a/crates/gateway-stt/tests/common/mod.rs +++ b/crates/gateway-stt/tests/common/mod.rs @@ -23,7 +23,7 @@ use tower::ServiceExt as _; pub(crate) const RECV_TIMEOUT: Duration = Duration::from_secs(10); pub(crate) fn fixture_runtime(with_final: bool) -> (SttState, SttRuntime) { - let source = gateway_transcribe::fixtures::require_model(); + let source = gateway_stt_engine::fixtures::require_model(); fixture_runtime_with_models(&source, with_final.then_some(source.as_path())) } diff --git a/crates/gateway-stt/tests/it/batch.rs b/crates/gateway-stt/tests/it/batch.rs index 97531e82..630f72a4 100644 --- a/crates/gateway-stt/tests/it/batch.rs +++ b/crates/gateway-stt/tests/it/batch.rs @@ -1,14 +1,14 @@ //! Characterization tests for physical-model batch transcription. use axum::http::StatusCode; -use gateway_transcribe::fixtures::jfk_samples; +use gateway_stt_engine::fixtures::jfk_samples; use crate::common::{copy_model_replacing_token, fixture_runtime_with_models, transcribe_batch}; #[tokio::test] #[ignore = "requires whisper test fixtures (tests/fixtures/)"] async fn batch_selects_each_loaded_physical_model_by_name() { - let interim_model = gateway_transcribe::fixtures::require_model(); + let interim_model = gateway_stt_engine::fixtures::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"); diff --git a/crates/gateway-stt/tests/it/legacy_stream.rs b/crates/gateway-stt/tests/it/legacy_stream.rs index a0bc5376..77565f3f 100644 --- a/crates/gateway-stt/tests/it/legacy_stream.rs +++ b/crates/gateway-stt/tests/it/legacy_stream.rs @@ -8,8 +8,8 @@ use std::time::Duration; use futures_util::{SinkExt as _, StreamExt as _}; -use gateway_transcribe::fixtures::jfk_samples; -use gateway_transcribe::{MIN_WINDOW_SAMPLES, SAMPLE_RATE, Segmenter}; +use gateway_stt_engine::fixtures::jfk_samples; +use gateway_stt_engine::{MIN_WINDOW_SAMPLES, SAMPLE_RATE, Segmenter}; use serde_json::json; use tokio_tungstenite::tungstenite; use tokio_tungstenite::tungstenite::client::IntoClientRequest; @@ -378,7 +378,7 @@ async fn wait_for_committed(socket: &mut JsonSocket, expected_word: &str) -> Str #[tokio::test] #[ignore = "requires whisper test fixtures (tests/fixtures/)"] async fn final_model_segments_and_tail_are_authoritative_at_stop() { - let interim_model = gateway_transcribe::fixtures::require_model(); + let interim_model = gateway_stt_engine::fixtures::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"); 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/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 9e13991a..0b64659d 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -355,7 +355,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### Step 4: Rename the engine without changing APIs - e2c8dcc3 - 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. From 672befef42d20de49a6bc9373334c5039e4bdc53 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sat, 5 Sep 2026 22:47:51 -0700 Subject: [PATCH 12/86] Move take ownership into gateway STT Move per-take speech state out of decode workers so each decode job is independent and every take owns its lifecycle. Carry immutable guidance and finalized history with each job, aggregate segment results in one ordered pipeline, and preserve interim fallback after failures. - `Take` consolidates guidance, finalized history, segmentation, local agreement, transcript aggregation, completion, and failure behind one per-take state object. - `FinalJob` carries all decode inputs and the reply channel, so final-model workers retain no take identity or transcript between jobs. - `Segmenter` moves from the engine surface to the gateway speech surface with take orchestration. - `run_final_pipeline` serializes closed segments and the closing tail, records only successful sample boundaries, and stops decode work after the first failure. - `next_interim` promotes token prefixes confirmed by two hypotheses and keeps committed text append-only. - `TestServer` now bounds fixture server and runtime cleanup at 30 seconds. - `guidance` has no end-to-end assertion from runtime activation through both batch and streaming decode paths. Design: new surface-growth @ crates/gateway-stt-engine/src/engine.rs::SttEngine::transcribe boundary: pub Design: new surface-growth @ crates/gateway-stt-engine/src/engine.rs::SttEngine::transcribe_final boundary: pub Design: new shared-parameter-cluster @ crates/gateway-stt-engine/src/engine.rs::SttEngine::transcribe_final boundary: pub Design: new shared-parameter-cluster @ crates/gateway-stt-engine/src/final_pass.rs::FinalTranscriber::transcribe Design: new surface-growth @ crates/gateway-stt/src/lib.rs::Segmenter boundary: pub Design: new value-object @ crates/gateway-stt/src/take.rs::AgreementSnapshot Design: new parameter-object @ crates/gateway-stt/src/take.rs::Take Design: new shared-mutable-state @ crates/gateway-stt/src/take.rs::TakeState Design: new message-passing @ crates/gateway-stt/src/take.rs::FinalPipeline Design: new pure-function @ crates/gateway-stt/src/take.rs::matching_token_prefix_end deps: &str,&str Design: new pure-function @ crates/gateway-stt/src/take.rs::token_spans deps: &str Design: new pure-function @ crates/gateway-stt/src/take.rs::after_token_prefix deps: &str,usize Design: new oversized-unit @ crates/gateway-stt/src/take.rs Design: extends oversized-unit @ crates/gateway-stt/tests/common/mod.rs Design: extends oversized-unit @ crates/gateway-stt/tests/it/legacy_stream.rs Violates: A2 - not determinable from diff Violates: A96 - not determinable from diff Deferred: configured guidance propagation lacks an end-to-end assertion Plan: 2026-09-05-2-generic-realtime-stt --- crates/gateway-stt-engine/AGENTS.md | 3 +- crates/gateway-stt-engine/Cargo.toml | 2 +- crates/gateway-stt-engine/src/engine.rs | 78 +-- crates/gateway-stt-engine/src/final_pass.rs | 461 +++---------- crates/gateway-stt-engine/src/lib.rs | 12 +- crates/gateway-stt-engine/src/worker.rs | 37 +- .../tests/native_whisper.rs | 48 +- crates/gateway-stt/AGENTS.md | 1 + crates/gateway-stt/src/api.rs | 6 +- crates/gateway-stt/src/lib.rs | 3 + crates/gateway-stt/src/runtime.rs | 39 +- .../src/segment.rs | 2 +- crates/gateway-stt/src/stt.rs | 222 ++---- crates/gateway-stt/src/take.rs | 651 ++++++++++++++++++ crates/gateway-stt/tests/common/mod.rs | 15 +- crates/gateway-stt/tests/it/legacy_stream.rs | 13 +- vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- vibe/archdoc-next.md | 8 + 18 files changed, 937 insertions(+), 666 deletions(-) rename crates/{gateway-stt-engine => gateway-stt}/src/segment.rs (99%) create mode 100644 crates/gateway-stt/src/take.rs diff --git a/crates/gateway-stt-engine/AGENTS.md b/crates/gateway-stt-engine/AGENTS.md index 8e2f26e5..fe731f35 100644 --- a/crates/gateway-stt-engine/AGENTS.md +++ b/crates/gateway-stt-engine/AGENTS.md @@ -1,8 +1,9 @@ # gateway-stt-engine -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. +This crate owns the Whisper transcription engine and nothing else: model ownership, stateless interim and final inference workers, 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`. +- Decode jobs are stateless. Guidance, finalized history, segmentation, LocalAgreement state, transcript aggregation, completion, and failure belong to `gateway-stt`; workers retain none of them between jobs and have no reset channel. - 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-stt-engine/Cargo.toml b/crates/gateway-stt-engine/Cargo.toml index 9f6bc596..3e4eda42 100644 --- a/crates/gateway-stt-engine/Cargo.toml +++ b/crates/gateway-stt-engine/Cargo.toml @@ -7,7 +7,7 @@ license.workspace = true repository.workspace = true publish = false -description = "PromptForge Whisper transcription engine: model ownership, inference workers, segmentation, and silence gating" +description = "PromptForge Whisper transcription engine: model ownership, stateless decode workers, and silence gating" [dependencies] shared-progress.workspace = true diff --git a/crates/gateway-stt-engine/src/engine.rs b/crates/gateway-stt-engine/src/engine.rs index 6991bf5e..cdfe633b 100644 --- a/crates/gateway-stt-engine/src/engine.rs +++ b/crates/gateway-stt-engine/src/engine.rs @@ -24,10 +24,6 @@ pub struct EngineConfig { /// `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. @@ -122,18 +118,13 @@ impl SttEngine { }; // 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 (transcriber, interim_init) = + Transcriber::spawn(library.clone(), &config.interim_model, interim_progress)?; let final_spawned = match &config.final_model { None => None, Some(final_model) => Some(FinalTranscriber::spawn( library, final_model, - &config.vocabulary, final_progress, )?), }; @@ -200,11 +191,16 @@ impl SttEngine { /// 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 + pub async fn transcribe( + &self, + samples: Vec, + guidance: Vec, + ) -> Result { + self.transcriber.transcribe(samples, guidance).await } - /// Transcribes one independent buffer with the final model. + /// Transcribes one independent buffer with the final model using only + /// the guidance and finalized history supplied on this job. /// /// This request does not read or change the active streaming take. /// @@ -214,48 +210,12 @@ impl SttEngine { pub async fn transcribe_final( &self, samples: Vec, + guidance: Vec, + finalized: String, ) -> 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 { + Some(final_pass) => Some(final_pass.transcribe(samples, guidance, finalized).await), None => None, - Some(final_pass) => Some(final_pass.finish(samples).await), } } } @@ -293,7 +253,7 @@ mod tests { }; let engine = SttEngine::new(&config).expect("engine loads the fixture model"); let text = engine - .transcribe(fixtures::jfk_samples()) + .transcribe(fixtures::jfk_samples(), Vec::new()) .await .expect("transcription succeeds"); assert!( @@ -340,7 +300,7 @@ mod tests { #[tokio::test] #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - async fn final_pass_entry_points_are_no_ops_without_a_final_model() { + async fn final_decode_is_absent_without_a_final_model() { let config = EngineConfig { library: fixtures::require_library(), interim_model: fixtures::require_model(), @@ -349,11 +309,11 @@ mod tests { ..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(), + engine + .transcribe_final(fixtures::jfk_samples(), Vec::new(), String::new()) + .await + .is_none(), "no final model means the caller falls back" ); } diff --git a/crates/gateway-stt-engine/src/final_pass.rs b/crates/gateway-stt-engine/src/final_pass.rs index 8dce3f9d..9d3360db 100644 --- a/crates/gateway-stt-engine/src/final_pass.rs +++ b/crates/gateway-stt-engine/src/final_pass.rs @@ -10,26 +10,18 @@ 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. +/// Final-model decoder state confined to its worker thread. +/// +/// Guidance and finalized history arrive on every job. The decoder retains +/// no take identity or transcript between jobs. #[derive(Debug)] -pub(crate) struct FinalPass { +struct FinalDecoder { 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. +impl FinalDecoder { + /// Loads the final model from `path`. /// /// # Errors /// Returns [`TranscribeError::LoadModel`] when the model file cannot be @@ -37,7 +29,6 @@ impl FinalPass { fn load( library: &WhisperLibrary, path: &Path, - vocabulary: &[String], progress: Option<&ProgressHandle>, ) -> Result { let (init_tx, init_rx) = std::sync::mpsc::sync_channel(1); @@ -50,94 +41,35 @@ impl FinalPass { _ => Err(TranscribeError::WorkerGone), }; }; - let glossary = fit_glossary(&ctx, vocabulary, GLOSSARY_TOKEN_BUDGET); - Ok(Self { - ctx, - state, - glossary, - transcript: String::new(), - last_prompt: String::new(), - }) + Ok(Self { ctx, state }) } - /// 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. + /// Executes one decode from only the state carried by this job. /// /// # 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 { + /// audio. + fn transcribe( + &mut self, + samples: &[f32], + guidance: &[String], + finalized: &str, + ) -> Result { if samples.len() < MIN_WINDOW_SAMPLES || is_silence(samples) { return Ok(String::new()); } - let prompt = final_prompt(&self.ctx, self.glossary.as_deref(), ""); + let glossary = fit_glossary(&self.ctx, guidance, GLOSSARY_TOKEN_BUDGET); + let prompt = final_prompt(&self.ctx, glossary.as_deref(), finalized); 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>, - }, +struct FinalJob { + samples: Vec, + guidance: Vec, + finalized: String, + reply: tokio::sync::oneshot::Sender>, } /// Handle to the final-pass worker thread: the large model transcribing @@ -160,25 +92,16 @@ impl FinalTranscriber { 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, - ); + final_worker_loop(&library, &path, progress.as_ref(), &job_rx, &init_tx); }) .map_err(TranscribeError::SpawnWorker)?; Ok(( @@ -190,57 +113,27 @@ impl FinalTranscriber { )) } - /// 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 { + /// Executes one independent final-model decode job. + pub(super) async fn transcribe( + &self, + samples: Vec, + guidance: Vec, + finalized: String, + ) -> 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 { + .send(FinalJob { samples, + guidance, + finalized, 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 { @@ -259,72 +152,31 @@ impl Drop for FinalTranscriber { 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 mut decoder = match FinalDecoder::load(library, path, progress) { + Ok(decoder) => { let _ = init_tx.send(Ok(())); - pass + decoder } 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); - } + let result = decoder.transcribe(&job.samples, &job.guidance, &job.finalized); + if let Err(error) = &result { + tracing::warn!(%error, "final-model transcription failed"); } + let _ = job.reply.send(result); } } #[cfg(test)] mod tests { - use std::time::Duration; - use super::*; use crate::engine::SttEngine; @@ -335,43 +187,27 @@ mod tests { 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"); + let mut decoder = FinalDecoder::load(&library, &fixtures::require_model(), None) + .expect("final decoder loads the fixture model"); + let first = decoder + .transcribe(&fixtures::jfk_samples(), &vocabulary, "") + .expect("segment one transcribes"); 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"); + let second = decoder + .transcribe(&fixtures::jfk_samples(), &vocabulary, &first) + .expect("segment two transcribes"); 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() { + async fn final_worker_returns_each_stateless_decode_to_its_caller() { let config = EngineConfig { library: fixtures::require_library(), interim_model: fixtures::require_model(), @@ -381,44 +217,20 @@ mod tests { ..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()) + let text = engine + .transcribe_final(fixtures::jfk_samples(), Vec::new(), String::new()) .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:?}" - ); + .expect("the final decode succeeds"); assert!( - segment_rx.try_recv().is_err(), - "the closing tail reports only through its reply, not the channel" + text.to_lowercase().contains("country"), + "the decode names the fixture's words: {text:?}" ); } #[tokio::test] #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - async fn final_finish_with_a_silent_tail_returns_empty_after_draining() { + async fn a_silent_final_job_returns_empty() { let config = EngineConfig { library: fixtures::require_library(), interim_model: fixtures::require_model(), @@ -428,158 +240,91 @@ mod tests { ..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]) + let text = engine + .transcribe_final(vec![0.0; SAMPLE_RATE], Vec::new(), String::new()) .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:?}" - ); + .expect("the final decode succeeds"); + assert!(text.is_empty(), "silence is skipped, not transcribed"); } #[test] #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn final_pass_conditions_each_segment_on_the_accumulated_transcript() { + fn final_decoder_uses_only_the_history_supplied_on_each_job() { let library = fixtures::require_loaded_library(); - let mut pass = FinalPass::load(&library, &fixtures::require_model(), &[], None) - .expect("final pass loads the fixture model"); + let mut decoder = FinalDecoder::load(&library, &fixtures::require_model(), None) + .expect("final decoder 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 = decoder + .transcribe(&jfk, &[], "") + .expect("segment one transcribes"); 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" - ); + let second = decoder + .transcribe(&jfk, &[], &first) + .expect("segment two transcribes"); 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() { + fn independent_final_jobs_do_not_require_a_reset() { let library = fixtures::require_loaded_library(); - let mut pass = FinalPass::load(&library, &fixtures::require_model(), &[], None) - .expect("final pass loads the fixture model"); + let mut decoder = FinalDecoder::load(&library, &fixtures::require_model(), None) + .expect("final decoder 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" - ); + let first = decoder.transcribe(&jfk, &[], "").expect("first job"); + let second = decoder.transcribe(&jfk, &[], "").expect("second job"); + assert_eq!(second, first, "jobs with equal inputs are independent"); } #[test] #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn standalone_transcription_does_not_change_the_streaming_take() { + fn one_final_job_cannot_change_another_jobs_history() { let library = fixtures::require_loaded_library(); - let mut pass = FinalPass::load(&library, &fixtures::require_model(), &[], None) - .expect("final pass loads the fixture model"); + let mut decoder = FinalDecoder::load(&library, &fixtures::require_model(), None) + .expect("final decoder 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")); + let prompt_sensitive = &jfk[6 * SAMPLE_RATE..8 * SAMPLE_RATE]; + let control = decoder + .transcribe(prompt_sensitive, &[], "") + .expect("preconditioned control job"); + let history = decoder + .transcribe(&jfk[..4 * SAMPLE_RATE], &[], "") + .expect("history source job"); + let conditioned = decoder + .transcribe(prompt_sensitive, &[], &history) + .expect("conditioned job"); + assert_ne!( + conditioned, control, + "the fixture must detect transcript conditioning" + ); + let standalone = decoder + .transcribe(prompt_sensitive, &[], "") + .expect("post-conditioned independent job"); assert_eq!( - pass.transcript(), - transcript, - "request-response transcription cannot change streaming take state" + standalone, control, + "a prior job's history cannot leak into a stateless decode" ); - assert_eq!(pass.last_prompt(), last_prompt); } #[test] #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn final_pass_skips_silence_without_touching_the_transcript() { + fn final_decoder_skips_silence() { 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]) + let mut decoder = FinalDecoder::load(&library, &fixtures::require_model(), None) + .expect("final decoder loads the fixture model"); + let text = decoder + .transcribe(&vec![0.0; SAMPLE_RATE * 2], &[], "history") .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" - ); + assert!(text.is_empty(), "silence transcribes to nothing"); } } diff --git a/crates/gateway-stt-engine/src/lib.rs b/crates/gateway-stt-engine/src/lib.rs index 88c4991d..71650b24 100644 --- a/crates/gateway-stt-engine/src/lib.rs +++ b/crates/gateway-stt-engine/src/lib.rs @@ -3,11 +3,11 @@ //! [`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: +//! set) holds the larger model and executes independent decode jobs. Callers +//! hand owned samples, guidance, and finalized history through channels and +//! await transcripts on oneshots, so blocking 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. @@ -15,13 +15,11 @@ 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. diff --git a/crates/gateway-stt-engine/src/worker.rs b/crates/gateway-stt-engine/src/worker.rs index 7f44c7ba..7ab453a2 100644 --- a/crates/gateway-stt-engine/src/worker.rs +++ b/crates/gateway-stt-engine/src/worker.rs @@ -19,6 +19,7 @@ const PREWARM_CHUNK: usize = 4 * 1024 * 1024; /// One transcription request handed to the worker thread. struct Job { samples: Vec, + guidance: Vec, reply: tokio::sync::oneshot::Sender>, } @@ -41,25 +42,16 @@ impl Transcriber { 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, - ); + worker_loop(&library, &path, progress.as_ref(), &job_rx, &init_tx); }) .map_err(TranscribeError::SpawnWorker)?; Ok(( @@ -71,14 +63,22 @@ impl Transcriber { )) } - /// Queues `samples` for transcription and awaits the trimmed text. - pub(super) async fn transcribe(&self, samples: Vec) -> Result { + /// Queues one independent decode and awaits the trimmed text. + pub(super) async fn transcribe( + &self, + samples: Vec, + guidance: 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 }) + .send(Job { + samples, + guidance, + reply, + }) .map_err(|_| TranscribeError::WorkerGone)?; reply_rx.await.map_err(|_| TranscribeError::WorkerGone)? } @@ -95,12 +95,11 @@ impl Drop for Transcriber { } } -/// The worker thread's body: load the model, fit the glossary prompt, then -/// transcribe jobs in arrival order until every sender is dropped. +/// The worker thread's body: load the model, then execute independent 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>, @@ -108,10 +107,10 @@ fn worker_loop( 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 interim pass carries no history, so this job's guidance gets + // the full prompt budget. + let glossary = fit_glossary(&ctx, &job.guidance, MAX_PROMPT_TOKENS); // 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( diff --git a/crates/gateway-stt-engine/tests/native_whisper.rs b/crates/gateway-stt-engine/tests/native_whisper.rs index b6eb1dce..1156284a 100644 --- a/crates/gateway-stt-engine/tests/native_whisper.rs +++ b/crates/gateway-stt-engine/tests/native_whisper.rs @@ -1,7 +1,5 @@ //! Native characterization of the packaged Whisper runtime contract. -use std::time::Duration; - use gateway_stt_engine::{EngineConfig, SttEngine, fixtures}; 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."; @@ -26,20 +24,19 @@ async fn packaged_runtime_preserves_native_transcription_contract() { library: library.clone(), interim_model: model.clone(), final_model: Some(model.clone()), - vocabulary: Vec::new(), window_seconds: 12, interval_ms: 500, }) .expect("packaged runtime and model load"); let interim = unprompted - .transcribe(samples.clone()) + .transcribe(samples.clone(), Vec::new()) .await .expect("interim decode succeeds"); assert_eq!(interim, JFK_TRANSCRIPT, "interim decode policy stays fixed"); let unprompted_clip = unprompted - .transcribe_final(prompt_sensitive_clip.clone()) + .transcribe_final(prompt_sensitive_clip.clone(), Vec::new(), String::new()) .await .expect("a final model is configured") .expect("unprompted final decode succeeds"); @@ -48,17 +45,20 @@ async fn packaged_runtime_preserves_native_transcription_contract() { "the prompt-sensitive clip has a fixed unprompted control" ); - let (segment_tx, segment_rx) = std::sync::mpsc::channel(); - unprompted.final_reset(segment_tx); - unprompted.final_submit(conditioning_clip); + let conditioning_transcript = unprompted + .transcribe_final(conditioning_clip, Vec::new(), String::new()) + .await + .expect("a final model is configured") + .expect("conditioning decode succeeds"); let conditioned_clip = unprompted - .final_finish(prompt_sensitive_clip.clone()) + .transcribe_final( + prompt_sensitive_clip.clone(), + Vec::new(), + conditioning_transcript.clone(), + ) .await .expect("a final model is configured") .expect("transcript-conditioned final decode succeeds"); - let conditioning_transcript = segment_rx - .recv_timeout(Duration::from_secs(1)) - .expect("conditioning segment reports its transcript"); assert_eq!( conditioning_transcript, CONDITIONING_TRANSCRIPT, "the accumulated transcript that conditions the tail stays fixed" @@ -76,22 +76,28 @@ async fn packaged_runtime_preserves_native_transcription_contract() { library, interim_model: model.clone(), final_model: Some(model.clone()), - vocabulary: vec!["one tree".to_string()], window_seconds: 12, interval_ms: 500, }) .expect("glossary-prompted engine loads"); - let (segment_tx, segment_rx) = std::sync::mpsc::channel(); - glossary_prompted.final_reset(segment_tx); - glossary_prompted.final_submit(prompt_sensitive_clip); + let glossary_clip = glossary_prompted + .transcribe_final( + prompt_sensitive_clip, + vec!["one tree".to_string()], + String::new(), + ) + .await + .expect("a final model is configured") + .expect("the glossary-conditioned segment decodes"); let silent_tail = glossary_prompted - .final_finish(vec![0.0; 16_000]) + .transcribe_final( + vec![0.0; 16_000], + vec!["one tree".to_string()], + glossary_clip.clone(), + ) .await .expect("a final model is configured") - .expect("the silent tail drains the glossary-conditioned segment"); - let glossary_clip = segment_rx - .recv_timeout(Duration::from_secs(1)) - .expect("glossary-conditioned segment reports its transcript"); + .expect("the silent tail decodes"); assert!(silent_tail.is_empty(), "silence remains gated"); assert_eq!( glossary_clip, GLOSSARY_CLIP_TRANSCRIPT, diff --git a/crates/gateway-stt/AGENTS.md b/crates/gateway-stt/AGENTS.md index 47b9e5bb..d5ecf48b 100644 --- a/crates/gateway-stt/AGENTS.md +++ b/crates/gateway-stt/AGENTS.md @@ -3,6 +3,7 @@ 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. - Runtime ownership only. Whisper inference primitives stay in `gateway-stt-engine`; artifact download and verification stay in `gateway-local::artifacts::ArtifactStore`. +- `take::Take` solely owns per-take guidance, finalized history, segmentation, LocalAgreement state, transcript aggregation, completion, and failure. - 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-stt-engine` 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. diff --git a/crates/gateway-stt/src/api.rs b/crates/gateway-stt/src/api.rs index abbfe850..196feed6 100644 --- a/crates/gateway-stt/src/api.rs +++ b/crates/gateway-stt/src/api.rs @@ -102,14 +102,14 @@ pub async fn transcribe( multipart: Multipart, ) -> Result { let form = parse_form(multipart).await?; - let Some((engine, role)) = state.select(&form.model) else { + let Some((engine, role, guidance)) = 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::Interim => engine.transcribe(samples, guidance).await, LoadedModelRole::Final => engine - .transcribe_final(samples) + .transcribe_final(samples, guidance, String::new()) .await .ok_or_else(|| TranscriptionError::ModelNotFound(form.model.clone()))?, } diff --git a/crates/gateway-stt/src/lib.rs b/crates/gateway-stt/src/lib.rs index 011e62f0..78da4ebd 100644 --- a/crates/gateway-stt/src/lib.rs +++ b/crates/gateway-stt/src/lib.rs @@ -9,8 +9,11 @@ mod api; mod runtime; +mod segment; mod stt; +mod take; pub use api::{MAX_AUDIO_BYTES, TranscriptionError, transcribe}; pub use runtime::{SttRuntime, SttRuntimeError, SttState}; +pub use segment::Segmenter; pub use stt::{gateway_routes, routes as stt_routes}; diff --git a/crates/gateway-stt/src/runtime.rs b/crates/gateway-stt/src/runtime.rs index c42629fc..fccbde89 100644 --- a/crates/gateway-stt/src/runtime.rs +++ b/crates/gateway-stt/src/runtime.rs @@ -18,6 +18,7 @@ pub(crate) enum LoadedModelRole { struct LoadedNames { interim: Option, final_model: Option, + guidance: Vec, } /// Shared active STT state used by both gateway HTTP surfaces. @@ -53,29 +54,48 @@ impl SttState { self.slot.is_active() } - pub(crate) fn select(&self, name: &str) -> Option<(Arc, LoadedModelRole)> { - let role = { + pub(crate) fn select( + &self, + name: &str, + ) -> Option<(Arc, LoadedModelRole, Vec)> { + let (role, guidance) = { let names = self.names.read().unwrap_or_else(PoisonError::into_inner); - if names.interim.as_deref() == Some(name) { + let role = 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)) + }?; + (role, names.guidance.clone()) + }; + self.slot.engine().map(|engine| (engine, role, guidance)) } pub(crate) fn subscribe(&self) -> tokio::sync::watch::Receiver { self.changes.subscribe() } - fn activate(&self, engine: SttEngine, interim: String, final_model: Option) { + pub(crate) fn guidance(&self) -> Vec { + self.names + .read() + .unwrap_or_else(PoisonError::into_inner) + .guidance + .clone() + } + + fn activate( + &self, + engine: SttEngine, + interim: String, + final_model: Option, + guidance: Vec, + ) { self.slot.activate(engine); *self.names.write().unwrap_or_else(PoisonError::into_inner) = LoadedNames { interim: Some(interim), final_model, + guidance, }; self.changes.send_modify(|generation| *generation += 1); } @@ -144,6 +164,7 @@ impl SttRuntime { .and_then(gateway_config::WorkshopConfig::stt) .cloned() .unwrap_or_default(); + let guidance = capture.vocabulary().to_vec(); let engine_config = engine_config(&capture, library, interim_path, models.final_model.as_ref()); let engine = SttEngine::new_with_progress( @@ -152,7 +173,7 @@ impl SttRuntime { ) .map_err(SttRuntimeError::Engine)?; let final_name = models.final_model.map(|(name, _)| name); - state.activate(engine, interim_name, final_name); + state.activate(engine, interim_name, final_name, guidance); Ok(SttRuntime { state, active: true, @@ -236,7 +257,6 @@ fn engine_config( 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(), } @@ -359,6 +379,7 @@ mod tests { *state.names.write().unwrap_or_else(PoisonError::into_inner) = LoadedNames { interim: Some("old".to_owned()), final_model: None, + guidance: Vec::new(), }; let runtime = SttRuntime::empty(state.clone()); assert!(state.select("old").is_none()); diff --git a/crates/gateway-stt-engine/src/segment.rs b/crates/gateway-stt/src/segment.rs similarity index 99% rename from crates/gateway-stt-engine/src/segment.rs rename to crates/gateway-stt/src/segment.rs index d35fac9b..0aad62e3 100644 --- a/crates/gateway-stt-engine/src/segment.rs +++ b/crates/gateway-stt/src/segment.rs @@ -11,7 +11,7 @@ use std::ops::Range; -use crate::{SAMPLE_RATE, is_silence}; +use gateway_stt_engine::{SAMPLE_RATE, is_silence}; /// Analysis frame length: 30 ms at 16 kHz, whisper.cpp's own VAD frame. const FRAME_SAMPLES: usize = SAMPLE_RATE * 30 / 1000; diff --git a/crates/gateway-stt/src/stt.rs b/crates/gateway-stt/src/stt.rs index 05f99fac..1cc36aa5 100644 --- a/crates/gateway-stt/src/stt.rs +++ b/crates/gateway-stt/src/stt.rs @@ -1,7 +1,7 @@ //! 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::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; use axum::Router; @@ -10,12 +10,13 @@ use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::http::{HeaderMap, StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum::routing::get; -use gateway_stt_engine::{MIN_WINDOW_SAMPLES, SAMPLE_RATE, Segmenter, SttEngine, is_silence, tail}; +use gateway_stt_engine::{MIN_WINDOW_SAMPLES, SAMPLE_RATE, SttEngine, is_silence}; use serde::Serialize; use tokio::sync::{mpsc, watch}; use workshop_server::{Activity, Push}; use crate::runtime::SttState; +use crate::take::Take; static NEXT_SESSION: AtomicU64 = AtomicU64::new(1); const MIC_PULSE_INTERVAL: Duration = Duration::from_millis(250); @@ -236,65 +237,6 @@ impl FinalFrame { } } -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>, @@ -331,25 +273,14 @@ fn spawn_interim( session: u64, generation: u64, engine: Arc, - state: 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 window = state.uncommitted_snapshot(engine.window_samples()); let tentative = if window.len() < MIN_WINDOW_SAMPLES || is_silence(&window) { String::new() } else { @@ -358,7 +289,7 @@ fn spawn_interim( "an interim pass over the uncommitted audio", Activity::General, ); - match engine.transcribe(window).await { + match engine.transcribe(window, state.guidance().to_vec()).await { Ok(text) => text, Err(error) => { reporter.push_activity( @@ -371,18 +302,11 @@ fn spawn_interim( } } }; - if !tentative.is_empty() { - committed_at_last_speech.clone_from(&committed_text); - } else if committed_text.len() <= committed_at_last_speech.len() { + let Some((finalized, tentative)) = state.next_interim(&tentative) else { 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)) + serde_json::to_string(&InterimFrame::new(finalized, tentative, generation)) else { continue; }; @@ -400,15 +324,14 @@ fn spawn_interim( async fn final_transcript( session: u64, engine: &SttEngine, - state: &TakeState, - segmenter: &Segmenter, + take: &Take, reporter: &Reporter, ) -> String { - let window = state.uncommitted_snapshot(segmenter.consumed(), engine.window_samples()); + let window = take.fallback_snapshot(engine.window_samples()); if window.len() < MIN_WINDOW_SAMPLES || is_silence(&window) { return String::new(); } - match engine.transcribe(window).await { + match engine.transcribe(window, take.guidance().to_vec()).await { Ok(text) => text, Err(error) => { reporter.push_failure("Transcription failed", error.to_string(), Activity::General); @@ -442,17 +365,8 @@ fn truncation_message(window_samples: usize, dropped: usize) -> String { /// 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()) - }; +fn warn_if_truncated(session: u64, engine: &SttEngine, take: &Take, reporter: &Reporter) { + let uncommitted = take.fallback_len(); let window = engine.window_samples(); let Some(dropped) = truncation_drop(uncommitted, window) else { return; @@ -473,67 +387,50 @@ fn warn_if_truncated( async fn stop_transcript( session: u64, engine: Option<&SttEngine>, - state: &TakeState, - segmenter: &Segmenter, + take: &Take, 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 { + match take.complete().await { Some(Ok(text)) => text, Some(Err(error)) => { - reporter.push_failure("Transcription failed", error.to_string(), Activity::General); + reporter.push_failure("Transcription failed", error.clone(), 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 + warn_if_truncated(session, engine, take, reporter); + let tail = final_transcript(session, engine, take, reporter).await; + take.fallback_transcript(&tail) } 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 + warn_if_truncated(session, engine, take, reporter); + final_transcript(session, engine, take, 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, + guidance: Vec, 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| { +) -> (Arc, Option) { + let state = Arc::new(Take::new(guidance, engine.cloned())); + let active = engine.map(|engine| { spawn_interim( session, generation, Arc::clone(engine), - Arc::clone(state), + Arc::clone(&state), reporter.clone(), ) }); @@ -543,24 +440,7 @@ fn begin_take( 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(); + (state, active) } async fn send_frame(socket: &mut WebSocket, frame: &F) -> bool { @@ -587,8 +467,7 @@ impl Drop for SessionClose { } struct SessionAudio { - state: Arc, - segmenter: Segmenter, + take: Arc, frames: u64, last_mic_pulse: Option, } @@ -596,8 +475,7 @@ struct SessionAudio { impl SessionAudio { fn new() -> Self { Self { - state: Arc::new(TakeState::default()), - segmenter: Segmenter::new(), + take: Arc::new(Take::new(Vec::new(), None)), frames: 0, last_mic_pulse: None, } @@ -611,7 +489,7 @@ impl SessionAudio { .map(|bytes| f32::from_le_bytes(*bytes)) .collect(); self.frames += samples.len() as u64; - self.state.lock_buffer().extend_from_slice(&samples); + self.take.append(&samples); if self .last_mic_pulse .is_none_or(|at| at.elapsed() >= MIC_PULSE_INTERVAL) @@ -626,7 +504,7 @@ impl SessionAudio { if let Some(engine) = engine && engine.has_final_pass() { - submit_closed_segments(engine, &self.state, &mut self.segmenter); + self.take.submit_closed_segments(); } } } @@ -658,8 +536,7 @@ async fn run_session( break; } take = None; - audio.state.reset(None); - audio.segmenter.reset(); + audio.take = Arc::new(Take::new(Vec::new(), None)); engine = stt.engine(); } interim = next_interim(&mut take) => { @@ -689,14 +566,15 @@ async fn run_session( if !send_frame(&mut socket, &StreamFrame::new(generation)).await { break; } - take = begin_take( + let (next_take, active) = begin_take( session, generation, engine.as_ref(), - &audio.state, - &mut audio.segmenter, + stt.guidance(), &reporter, ); + audio.take = next_take; + take = active; } STT_STOP => { take = None; @@ -708,8 +586,7 @@ async fn run_session( let text = stop_transcript( session, engine.as_deref(), - &audio.state, - &audio.segmenter, + &audio.take, &reporter, ) .await; @@ -812,25 +689,6 @@ mod tests { 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); diff --git a/crates/gateway-stt/src/take.rs b/crates/gateway-stt/src/take.rs new file mode 100644 index 00000000..0dda06ad --- /dev/null +++ b/crates/gateway-stt/src/take.rs @@ -0,0 +1,651 @@ +//! Per-take speech state and finalization ownership. + +use std::future::Future; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use gateway_stt_engine::{SttEngine, TranscribeError, tail}; +use tokio::sync::{mpsc, oneshot}; + +use crate::segment::Segmenter; + +#[derive(Debug, PartialEq, Eq)] +struct AgreementSnapshot { + agreed: String, + tentative: String, +} + +#[derive(Debug, Default)] +struct LocalAgreement { + previous: String, +} + +impl LocalAgreement { + fn observe(&mut self, hypothesis: &str) -> AgreementSnapshot { + let agreed_end = if self.previous.is_empty() { + 0 + } else { + matching_token_prefix_end(&self.previous, hypothesis) + }; + self.previous.clear(); + self.previous.push_str(hypothesis); + AgreementSnapshot { + agreed: hypothesis[..agreed_end].to_owned(), + tentative: hypothesis[agreed_end..].to_owned(), + } + } +} + +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) +} + +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 +} + +fn after_token_prefix(text: &str, tokens: usize) -> &str { + if tokens == 0 { + return text; + } + token_spans(text) + .get(tokens - 1) + .map_or("", |(_, _, end)| &text[*end..]) +} + +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 FinalizedState { + text: String, + failure: Option, + samples: usize, +} + +#[derive(Debug, Default)] +struct InterimState { + agreement: LocalAgreement, + promoted: String, + agreement_finalized: String, + committed: String, + last_committed: String, + last_tentative: String, + finalized_at_last_speech: String, +} + +#[derive(Debug, Default)] +struct TakeState { + buffer: Mutex>, + segmenter: Mutex, + finalized: Mutex, + interim: Mutex, +} + +impl TakeState { + fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn finalized(&self) -> String { + Self::lock(&self.finalized).text.clone() + } + + 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(_) => {} + } + } + + fn record_failure(&self, failure: String) { + let mut state = Self::lock(&self.finalized); + if state.failure.is_none() { + state.failure = Some(failure); + } + } + + fn has_failure(&self) -> bool { + Self::lock(&self.finalized).failure.is_some() + } + + fn finalized_samples(&self) -> usize { + Self::lock(&self.finalized).samples + } + + fn completion(&self) -> Result { + let mut state = Self::lock(&self.finalized); + match state.failure.take() { + Some(failure) => Err(failure), + None => Ok(state.text.clone()), + } + } +} + +#[derive(Debug)] +enum FinalCommand { + Segment { + samples: Vec, + end: usize, + }, + Complete { + tail: Vec, + reply: oneshot::Sender>, + }, +} + +#[derive(Debug)] +struct FinalPipeline { + commands: mpsc::UnboundedSender, + task: tokio::task::JoinHandle<()>, +} + +impl Drop for FinalPipeline { + fn drop(&mut self) { + self.task.abort(); + } +} + +/// All mutable and immutable state belonging to one speech take. +#[derive(Debug)] +pub(crate) struct Take { + guidance: Arc<[String]>, + state: Arc, + 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(|engine| engine.has_final_pass()) + .map(|engine| spawn_final_pipeline(engine, Arc::clone(&guidance), Arc::clone(&state))); + Self { + guidance, + state, + 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) { + let Some(pipeline) = &self.final_pipeline else { + return; + }; + loop { + let segment = { + let buffer = TakeState::lock(&self.state.buffer); + TakeState::lock(&self.state.segmenter) + .poll(&buffer) + .map(|range| (buffer[range.clone()].to_vec(), range.end)) + }; + let Some((samples, end)) = segment else { + break; + }; + if pipeline + .commands + .send(FinalCommand::Segment { samples, end }) + .is_err() + { + self.state + .record_failure("final transcription pipeline exited".to_owned()); + break; + } + } + } + + pub(crate) fn consumed(&self) -> usize { + TakeState::lock(&self.state.segmenter).consumed() + } + + 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 fallback_snapshot(&self, window_samples: usize) -> Vec { + let finalized = self.state.finalized_samples(); + let buffer = TakeState::lock(&self.state.buffer); + let pending = &buffer[finalized.min(buffer.len())..]; + tail(pending, window_samples).to_vec() + } + + pub(crate) fn fallback_len(&self) -> usize { + let finalized = self.state.finalized_samples(); + TakeState::lock(&self.state.buffer) + .len() + .saturating_sub(finalized) + } + + pub(crate) fn finalized(&self) -> String { + self.state.finalized() + } + + pub(crate) fn fallback_transcript(&self, tail: &str) -> String { + let mut transcript = self.finalized(); + append_transcript(&mut transcript, tail); + transcript + } + + #[cfg(test)] + fn record_finalized(&self, result: Result) { + self.state.record_finalized(result, None); + } + + #[cfg(test)] + fn record_failure(&self, failure: impl Into) { + self.state.record_failure(failure.into()); + } + + #[cfg(test)] + fn take_failure(&self) -> Option { + TakeState::lock(&self.state.finalized).failure.take() + } + + pub(crate) fn next_interim(&self, hypothesis: &str) -> Option<(String, String)> { + let finalized = self.finalized(); + let mut state = TakeState::lock(&self.state.interim); + if state.agreement_finalized != finalized { + let finalized_delta = finalized + .strip_prefix(&state.agreement_finalized) + .unwrap_or_default(); + let unpromoted = + after_token_prefix(finalized_delta, token_spans(&state.promoted).len()); + append_transcript(&mut state.committed, unpromoted.trim()); + state.agreement = LocalAgreement::default(); + state.promoted.clear(); + state.agreement_finalized.clone_from(&finalized); + } + let suffix_start = matching_token_prefix_end(&state.promoted, hypothesis); + let suffix = hypothesis[suffix_start..].trim_start(); + let agreement = state.agreement.observe(suffix); + let tentative = agreement.tentative.trim_start().to_owned(); + state.agreement.previous.clone_from(&tentative); + let promoted = agreement.agreed.trim(); + append_transcript(&mut state.promoted, promoted); + append_transcript(&mut state.committed, promoted); + if !hypothesis.is_empty() { + state.finalized_at_last_speech.clone_from(&finalized); + } else if finalized.len() <= state.finalized_at_last_speech.len() { + return None; + } + let committed = state.committed.clone(); + if committed == state.last_committed && tentative == state.last_tentative { + return None; + } + state.last_committed.clone_from(&committed); + state.last_tentative.clone_from(&tentative); + Some((committed, tentative)) + } + + pub(crate) async fn complete(&self) -> Option> { + let pipeline = self.final_pipeline.as_ref()?; + let tail = { + let consumed = self.consumed(); + let buffer = TakeState::lock(&self.state.buffer); + buffer[consumed.min(buffer.len())..].to_vec() + }; + let (reply, reply_rx) = oneshot::channel(); + if pipeline + .commands + .send(FinalCommand::Complete { tail, reply }) + .is_err() + { + return Some(Err("final transcription pipeline exited".to_owned())); + } + Some( + reply_rx + .await + .unwrap_or_else(|_| Err("final transcription pipeline exited".to_owned())), + ) + } +} + +fn spawn_final_pipeline( + engine: Arc, + guidance: Arc<[String]>, + state: Arc, +) -> FinalPipeline { + let (commands, receiver) = mpsc::unbounded_channel(); + let task = tokio::spawn(run_final_pipeline( + receiver, + guidance, + state, + move |samples, guidance, finalized| { + let engine = Arc::clone(&engine); + async move { engine.transcribe_final(samples, guidance, finalized).await } + }, + )); + FinalPipeline { commands, task } +} + +async fn run_final_pipeline( + mut receiver: mpsc::UnboundedReceiver, + guidance: Arc<[String]>, + state: Arc, + mut decode: D, +) where + D: FnMut(Vec, Vec, String) -> F, + F: Future>>, +{ + while let Some(command) = receiver.recv().await { + let (samples, finalized_samples, completion) = match command { + FinalCommand::Segment { samples, end } => (samples, Some(end), None), + FinalCommand::Complete { tail, reply } => (tail, None, Some(reply)), + }; + if !state.has_failure() { + let finalized = state.finalized(); + match decode(samples, guidance.to_vec(), finalized).await { + Some(result) => state.record_finalized(result, finalized_samples), + None => { + state.record_failure("final transcription worker is unavailable".to_owned()); + } + } + } + if let Some(reply) = completion { + let _ = reply.send(state.completion()); + break; + } + } + drop(guidance); + drop(state); +} + +#[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, LocalAgreement, Take, run_final_pipeline}; + + #[test] + fn local_agreement_requires_two_hypotheses_and_preserves_whitespace() { + let mut agreement = LocalAgreement::default(); + let first = agreement.observe("ask not what"); + assert_eq!(first.agreed, ""); + assert_eq!(first.tentative, "ask not what"); + + let second = agreement.observe("ask not who"); + assert_eq!(second.agreed, "ask not"); + assert_eq!(second.tentative, " who"); + assert_eq!( + format!("{}{}", second.agreed, second.tentative), + "ask not who" + ); + } + + #[test] + fn production_interims_promote_locally_agreed_words() { + let take = Take::without_final(Vec::new()); + assert_eq!( + take.next_interim("ask not what"), + Some((String::new(), "ask not what".to_owned())) + ); + assert_eq!( + take.next_interim("ask not who"), + Some(("ask not".to_owned(), "who".to_owned())) + ); + assert_eq!( + take.next_interim("ask not who"), + Some(("ask not who".to_owned(), String::new())) + ); + assert_eq!( + take.next_interim("ask not when"), + Some(("ask not who".to_owned(), "when".to_owned())) + ); + } + + #[test] + fn finalization_preserves_a_divergent_promoted_prefix() { + let take = Take::without_final(Vec::new()); + assert_eq!( + take.next_interim("ask not your country"), + Some((String::new(), "ask not your country".to_owned())) + ); + let promoted = take + .next_interim("ask not your country") + .expect("the repeated hypothesis promotes its words") + .0; + assert_eq!(promoted, "ask not your country"); + + take.record_finalized(Ok("ask not your kingdom".to_owned())); + let committed = take + .next_interim("new tail") + .expect("speech after finalization emits another interim") + .0; + + assert_eq!(committed, promoted); + assert!(committed.starts_with("ask not your country")); + } + + #[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"); + } + + #[test] + fn tail_failure_fallback_preserves_successful_closed_segments() { + let take = Take::without_final(Vec::new()); + take.record_finalized(Ok("successful segment".to_owned())); + take.record_failure("tail failed"); + + assert_eq!(take.state.completion(), Err("tail failed".to_owned())); + assert_eq!( + take.fallback_transcript("fallback tail"), + "successful segment fallback tail" + ); + } + + #[test] + fn closed_segment_failure_does_not_duplicate_a_successful_tail_in_fallback() { + let take = Take::without_final(Vec::new()); + take.record_failure("closed segment failed"); + take.record_finalized(Ok("successful tail".to_owned())); + + assert_eq!( + take.state.completion(), + Err("closed segment failed".to_owned()) + ); + assert_eq!(take.fallback_transcript("fallback tail"), "fallback tail"); + } + + #[tokio::test] + async fn failed_segment_audio_remains_in_the_fallback_window() { + let take = Take::without_final(Vec::new()); + let successful = vec![1.0; 4]; + let failed = vec![2.0; 3]; + let skipped = vec![3.0; 2]; + let tail = vec![4.0]; + take.append( + &[ + successful.clone(), + failed.clone(), + skipped.clone(), + tail.clone(), + ] + .concat(), + ); + + let (commands, receiver) = mpsc::unbounded_channel(); + let calls = Arc::new(AtomicUsize::new(0)); + let decode_calls = Arc::clone(&calls); + let task = tokio::spawn(run_final_pipeline( + receiver, + Arc::from([]), + Arc::clone(&take.state), + move |_, _, _| { + let call = decode_calls.fetch_add(1, Ordering::SeqCst); + async move { + Some(match call { + 0 => Ok("successful".to_owned()), + 1 => return None, + _ => panic!("decoding must stop after the first failure"), + }) + } + }, + )); + commands + .send(FinalCommand::Segment { + samples: successful, + end: 4, + }) + .expect("the successful segment queues"); + commands + .send(FinalCommand::Segment { + samples: failed.clone(), + end: 7, + }) + .expect("the failed segment queues"); + commands + .send(FinalCommand::Segment { + samples: skipped.clone(), + end: 9, + }) + .expect("the skipped segment queues"); + let (reply, completion) = oneshot::channel(); + commands + .send(FinalCommand::Complete { + tail: tail.clone(), + reply, + }) + .expect("completion queues"); + + assert!( + completion + .await + .expect("the completion pipeline replies") + .is_err() + ); + tokio::time::timeout(Duration::from_secs(1), task) + .await + .expect("the completed pipeline terminates before the deadline") + .expect("the completed pipeline task succeeds"); + assert_eq!(calls.load(Ordering::SeqCst), 2); + assert_eq!( + take.fallback_snapshot(usize::MAX), + [failed, skipped, tail].concat() + ); + } + + #[tokio::test] + async fn completed_pipeline_releases_its_retained_dependency() { + let (commands, receiver) = mpsc::unbounded_channel(); + 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, + 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(), + reply, + }) + .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/tests/common/mod.rs b/crates/gateway-stt/tests/common/mod.rs index 98e25bd2..bbb8a445 100644 --- a/crates/gateway-stt/tests/common/mod.rs +++ b/crates/gateway-stt/tests/common/mod.rs @@ -21,6 +21,7 @@ use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use tower::ServiceExt as _; pub(crate) const RECV_TIMEOUT: Duration = Duration::from_secs(10); +const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(30); pub(crate) fn fixture_runtime(with_final: bool) -> (SttState, SttRuntime) { let source = gateway_stt_engine::fixtures::require_model(); @@ -156,11 +157,19 @@ impl TestServer { pub(crate) async fn shutdown(mut self) { self.task.abort(); - let _ = (&mut self.task).await; + let _ = tokio::time::timeout(SHUTDOWN_TIMEOUT, &mut self.task) + .await + .expect("gateway STT fixture server stops before the cleanup deadline"); if let Some(runtime) = self.runtime.take() { - tokio::task::spawn_blocking(move || runtime.shutdown()) + let (finished_tx, finished_rx) = tokio::sync::oneshot::channel(); + std::thread::spawn(move || { + runtime.shutdown(); + let _ = finished_tx.send(()); + }); + tokio::time::timeout(SHUTDOWN_TIMEOUT, finished_rx) .await - .expect("fixture runtime shutdown task succeeds"); + .expect("fixture runtime stops before the cleanup deadline") + .expect("fixture runtime cleanup thread reports completion"); } } } diff --git a/crates/gateway-stt/tests/it/legacy_stream.rs b/crates/gateway-stt/tests/it/legacy_stream.rs index 77565f3f..bbc86703 100644 --- a/crates/gateway-stt/tests/it/legacy_stream.rs +++ b/crates/gateway-stt/tests/it/legacy_stream.rs @@ -8,8 +8,9 @@ use std::time::Duration; use futures_util::{SinkExt as _, StreamExt as _}; +use gateway_stt::Segmenter; use gateway_stt_engine::fixtures::jfk_samples; -use gateway_stt_engine::{MIN_WINDOW_SAMPLES, SAMPLE_RATE, Segmenter}; +use gateway_stt_engine::{MIN_WINDOW_SAMPLES, SAMPLE_RATE}; use serde_json::json; use tokio_tungstenite::tungstenite; use tokio_tungstenite::tungstenite::client::IntoClientRequest; @@ -146,6 +147,7 @@ async fn a_take_counts_pcm_frames_and_tags_the_final_with_its_generation() { "frames are counted, the partial sample is dropped, and no engine means an empty transcript" ); socket.close().await; + server.shutdown().await; } #[tokio::test] @@ -194,6 +196,7 @@ async fn the_workshop_relay_can_request_private_status_frames() { }) ); socket.close(None).await.expect("socket closes"); + server.shutdown().await; } #[tokio::test] @@ -234,6 +237,7 @@ async fn a_restart_increments_the_generation_and_a_new_connection_resets_it() { ); socket.close().await; second.close().await; + server.shutdown().await; } #[tokio::test] @@ -256,6 +260,7 @@ async fn stt_upgrade_keeps_the_loopback_origin_allowlist() { } other => panic!("expected HTTP refusal, got {other:?}"), } + server.shutdown().await; } #[tokio::test] @@ -275,6 +280,7 @@ async fn unknown_text_is_ignored_without_changing_the_take() { json!({"type": "final", "text": "", "frames": 10, "generation": 1}) ); socket.close().await; + server.shutdown().await; } #[tokio::test] @@ -320,6 +326,7 @@ async fn speech_produces_generation_tagged_interim_and_final_frames() { "the final transcript names the fixture's words: {text:?}" ); socket.close().await; + server.shutdown().await; } #[tokio::test] @@ -341,6 +348,7 @@ async fn interim_only_stop_keeps_speech_before_a_silence_gap() { "the fallback decodes the whole take, nothing consumed early: {text:?}" ); socket.close().await; + server.shutdown().await; } #[tokio::test] @@ -359,6 +367,7 @@ async fn silence_produces_no_interims_and_an_empty_final() { "the first message after silence is the stop reply, not an interim" ); socket.close().await; + server.shutdown().await; } async fn wait_for_committed(socket: &mut JsonSocket, expected_word: &str) -> String { @@ -479,6 +488,7 @@ async fn stop_at_a_segment_boundary_returns_the_committed_prefix() { "no uncommitted speech means no tail transcription" ); socket.close().await; + server.shutdown().await; } #[tokio::test] @@ -533,4 +543,5 @@ async fn interim_frames_keep_committed_text_append_only() { "the assembled transcript opens with the last committed prefix" ); socket.close().await; + server.shutdown().await; } diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 0b64659d..9609669b 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -366,7 +366,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### Step 5: Move take ownership into gateway-stt - dff68665 - 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index bd4286d3..ef964075 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -128,3 +128,11 @@ - [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 +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 +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 +N6 | observation | oversized-unit @ crates/gateway-stt/src/take.rs: adds a 651-line take module | Move take ownership into gateway STT +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 +N9 | observation | Violates A2 @ crates/gateway-stt/src/runtime.rs: not determinable from diff | Move take ownership into gateway STT +N10 | observation | Violates A96 @ crates/gateway-stt/src/api.rs: not determinable from diff | Move take ownership into gateway STT From c7c1c1f7561f42dc606e1d595bff0be8a20ca7b0 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 00:06:04 -0700 Subject: [PATCH 13/86] Separate Whisper from the STT engine Make speech decoding backend-neutral while keeping Whisper construction, prompting, progress, and error translation in a safe adapter. Inject model factories into dedicated workers so model creation and decoding stay on their owning threads. Preserve batch and native transcription behavior through relocated and expanded tests. - `Decoder` and `ModelFactory` establish backend strategy contracts, while `WhisperModelFactory` contains safe model construction and decode policy. - `SttEngine::new` constructs each decoder on its owning worker and returns initialization errors before activation. - `native_whisper.rs` relocates native characterization and adds isolation, optional-final, and load-progress checks. These fixture-dependent tests remain ignored. - `std::sync::mpsc::channel` leaves both worker job queues unbounded. - `require_fixture` duplicates native fixture loading across unit and integration test support. Design: new strategy @ crates/gateway-stt-engine/src/decoder.rs::Decoder boundary: pub Design: new surface-growth @ crates/gateway-stt-engine/src/decoder.rs::Decoder boundary: pub Design: new strategy @ crates/gateway-stt-engine/src/decoder.rs::ModelFactory boundary: pub Design: new surface-growth @ crates/gateway-stt-engine/src/decoder.rs::ModelFactory boundary: pub Design: new facade @ crates/gateway-stt-backend-whisper/src/lib.rs boundary: pub Design: new constructor-injection @ crates/gateway-stt-engine/src/engine.rs::SttEngine::new Design: new flag-parameter @ crates/gateway-stt-backend-whisper/src/model.rs::WhisperDecoder::load Design: new flag-parameter @ crates/gateway-stt-engine/src/worker.rs::worker_loop deps: &dyn ModelFactory,&std::sync::mpsc::Receiver,&std::sync::mpsc::SyncSender>,bool Design: replaces shared-mutable-state @ crates/gateway-stt/src/runtime.rs::SttSlot was: crates/gateway-stt-engine/src/slot.rs::SttSlot Design: new global-state @ crates/gateway-stt-backend-whisper/src/prompt.rs::NATIVE_TEST Design: new global-state @ crates/gateway-stt-backend-whisper/tests/native_whisper.rs::NATIVE_TEST Design: new clone-block @ crates/gateway-stt/src/test_fixtures.rs Design: new clone-block @ crates/gateway-stt/tests/common/mod.rs Violates: A2 - crates/gateway-stt/src/runtime.rs is not determinable from diff Pending: N9 - compounds Deferred: model worker queues remain unbounded Deferred: native Whisper characterization remains ignored behind external fixtures Plan: 2026-09-05-2-generic-realtime-stt --- .gitignore | 2 +- Cargo.lock | 13 +- Cargo.toml | 1 + crates/gateway-stt-backend-whisper/AGENTS.md | 6 + crates/gateway-stt-backend-whisper/Cargo.toml | 24 + .../gateway-stt-backend-whisper/src/config.rs | 32 ++ crates/gateway-stt-backend-whisper/src/lib.rs | 8 + .../gateway-stt-backend-whisper/src/model.rs | 301 ++++++++++ .../gateway-stt-backend-whisper/src/prompt.rs | 233 ++++++++ .../tests/native_whisper.rs | 255 +++++++++ crates/gateway-stt-engine/AGENTS.md | 10 +- crates/gateway-stt-engine/Cargo.toml | 21 +- crates/gateway-stt-engine/src/decoder.rs | 42 ++ crates/gateway-stt-engine/src/engine.rs | 524 +++++++++--------- crates/gateway-stt-engine/src/error.rs | 45 +- crates/gateway-stt-engine/src/final_pass.rs | 330 ----------- crates/gateway-stt-engine/src/lib.rs | 237 +------- crates/gateway-stt-engine/src/policy.rs | 52 ++ crates/gateway-stt-engine/src/prompt.rs | 245 -------- crates/gateway-stt-engine/src/slot.rs | 74 --- crates/gateway-stt-engine/src/worker.rs | 291 ++-------- .../tests/native_whisper.rs | 114 ---- crates/gateway-stt/AGENTS.md | 6 +- crates/gateway-stt/Cargo.toml | 6 +- crates/gateway-stt/src/api.rs | 4 +- crates/gateway-stt/src/lib.rs | 2 + crates/gateway-stt/src/runtime.rs | 68 ++- crates/gateway-stt/src/take.rs | 14 +- crates/gateway-stt/src/test_fixtures.rs | 37 ++ crates/gateway-stt/tests/common/mod.rs | 36 +- crates/gateway-stt/tests/it/batch.rs | 8 +- crates/gateway-stt/tests/it/legacy_stream.rs | 7 +- vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- vibe/archdoc-next.md | 8 +- 34 files changed, 1480 insertions(+), 1578 deletions(-) create mode 100644 crates/gateway-stt-backend-whisper/AGENTS.md create mode 100644 crates/gateway-stt-backend-whisper/Cargo.toml create mode 100644 crates/gateway-stt-backend-whisper/src/config.rs create mode 100644 crates/gateway-stt-backend-whisper/src/lib.rs create mode 100644 crates/gateway-stt-backend-whisper/src/model.rs create mode 100644 crates/gateway-stt-backend-whisper/src/prompt.rs create mode 100644 crates/gateway-stt-backend-whisper/tests/native_whisper.rs create mode 100644 crates/gateway-stt-engine/src/decoder.rs delete mode 100644 crates/gateway-stt-engine/src/final_pass.rs create mode 100644 crates/gateway-stt-engine/src/policy.rs delete mode 100644 crates/gateway-stt-engine/src/prompt.rs delete mode 100644 crates/gateway-stt-engine/src/slot.rs delete mode 100644 crates/gateway-stt-engine/tests/native_whisper.rs create mode 100644 crates/gateway-stt/src/test_fixtures.rs diff --git a/.gitignore b/.gitignore index 278711ad..2e450aac 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-stt-engine/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. diff --git a/Cargo.lock b/Cargo.lock index 5d282c04..e17868db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1999,7 +1999,7 @@ dependencies = [ "futures-util", "gateway-config", "gateway-local", - "gateway-stt", + "gateway-stt-backend-whisper", "gateway-stt-engine", "hound", "serde", @@ -2016,7 +2016,7 @@ dependencies = [ ] [[package]] -name = "gateway-stt-engine" +name = "gateway-stt-backend-whisper" version = "0.2.0" dependencies = [ "gateway-stt-engine", @@ -2024,11 +2024,18 @@ dependencies = [ "hound", "shared-progress", "tempfile", - "thiserror 2.0.19", "tokio", "tracing", ] +[[package]] +name = "gateway-stt-engine" +version = "0.2.0" +dependencies = [ + "thiserror 2.0.19", + "tokio", +] + [[package]] name = "gateway-web-search" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 61fc6a24..b600cbff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ 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-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/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..4f246834 --- /dev/null +++ b/crates/gateway-stt-backend-whisper/Cargo.toml @@ -0,0 +1,24 @@ +[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] +hound.workspace = true +tempfile.workspace = true +tokio.workspace = true + +[lints] +workspace = true 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..546a89f1 --- /dev/null +++ b/crates/gateway-stt-backend-whisper/src/model.rs @@ -0,0 +1,301 @@ +//! Whisper model factory, decoder, progress, and error translation. + +use std::io::Read; +use std::path::Path; + +use gateway_stt_engine::{Decoder, MIN_WINDOW_SAMPLES, ModelFactory, TranscribeError, is_silence}; +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, + }) + } +} + +impl ModelFactory for WhisperModelFactory { + fn create_interim(&self) -> Result, TranscribeError> { + let progress = self + .config + .progress + .as_ref() + .map(|handle| handle.child("interim", 1.0)); + WhisperDecoder::load( + &self.library, + &self.config.interim_model, + progress.as_ref(), + false, + ) + .map(|decoder| Box::new(decoder) as Box) + } + + fn create_final(&self) -> Result>, TranscribeError> { + let Some(path) = &self.config.final_model else { + return Ok(None); + }; + let progress = self + .config + .progress + .as_ref() + .map(|handle| handle.child("final", 1.0)); + WhisperDecoder::load(&self.library, path, progress.as_ref(), true) + .map(|decoder| Some(Box::new(decoder) as Box)) + } + + fn gpu_available(&self) -> bool { + self.gpu_available + } +} + +#[derive(Debug)] +struct WhisperDecoder { + context: WhisperContext, + state: WhisperState, + final_pass: bool, +} + +impl WhisperDecoder { + fn load( + library: &WhisperLibrary, + path: &Path, + progress: Option<&ProgressHandle>, + final_pass: bool, + ) -> 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, + final_pass, + }) + } +} + +impl Decoder for WhisperDecoder { + fn transcribe( + &mut self, + samples: &[f32], + guidance: &[String], + finalized: &str, + ) -> Result { + if self.final_pass && (samples.len() < MIN_WINDOW_SAMPLES || is_silence(samples)) { + return Ok(String::new()); + } + let glossary_budget = if self.final_pass { + GLOSSARY_TOKEN_BUDGET + } else { + MAX_PROMPT_TOKENS + }; + let glossary = fit_glossary(&self.context, guidance, glossary_budget); + let prompt = if self.final_pass { + Some(final_prompt(&self.context, glossary.as_deref(), finalized)) + } else { + glossary + }; + transcribe_blocking( + &mut self.state, + samples, + prompt.as_deref(), + !self.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..e0221e07 --- /dev/null +++ b/crates/gateway-stt-backend-whisper/src/prompt.rs @@ -0,0 +1,233 @@ +//! 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_whisper_ffi::WhisperLibrary; + + use super::*; + + static NATIVE_TEST: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn require_fixture(variable: &str, fallback: &str) -> PathBuf { + let path = std::env::var_os(variable).map_or_else( + || { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(fallback) + }, + PathBuf::from, + ); + assert!( + path.is_file(), + "native test fixture is missing: {}", + path.display() + ); + path + } + + fn require_context() -> WhisperContext { + let library_path = require_fixture("PROMPTFORGE_WHISPER_LIBRARY", "whisper.dll"); + let model_path = require_fixture("PROMPTFORGE_WHISPER_MODEL", "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..6deb2047 --- /dev/null +++ b/crates/gateway-stt-backend-whisper/tests/native_whisper.rs @@ -0,0 +1,255 @@ +//! Native characterization of the packaged Whisper backend contract. + +#![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::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") +} + +fn require_fixture(variable: &str, fallback: &str) -> PathBuf { + let path = + std::env::var_os(variable).map_or_else(|| fixture_dir().join(fallback), PathBuf::from); + assert!( + path.is_file(), + "native test fixture is missing: {}", + path.display() + ); + path +} + +fn jfk_samples() -> Vec { + let path = require_fixture("PROMPTFORGE_WHISPER_AUDIO", "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"); + SttEngine::new(factory, 12, 500).expect("backend models load") +} + +#[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", "whisper.dll"); + let model = temp.path().join("ggml-tiny.en.bin"); + std::fs::copy( + require_fixture("PROMPTFORGE_WHISPER_MODEL", "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 + .transcribe(samples.clone(), Vec::new()) + .await + .expect("interim decode succeeds"); + assert_eq!(interim, JFK_TRANSCRIPT, "interim decode policy stays fixed"); + + let unprompted_clip = unprompted + .transcribe_final(prompt_sensitive_clip.clone(), Vec::new(), String::new()) + .await + .expect("a final model is configured") + .expect("unprompted final decode succeeds"); + assert_eq!(unprompted_clip, UNPROMPTED_CLIP_TRANSCRIPT); + + let conditioning_transcript = unprompted + .transcribe_final(conditioning_clip, Vec::new(), String::new()) + .await + .expect("a final model is configured") + .expect("conditioning decode succeeds"); + let conditioned_clip = unprompted + .transcribe_final( + prompt_sensitive_clip.clone(), + Vec::new(), + conditioning_transcript.clone(), + ) + .await + .expect("a final model is configured") + .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 + .transcribe_final( + prompt_sensitive_clip, + vec!["one tree".to_string()], + String::new(), + ) + .await + .expect("a final model is configured") + .expect("the glossary-conditioned segment decodes"); + let silent_tail = glossary_prompted + .transcribe_final( + vec![0.0; 16_000], + vec!["one tree".to_string()], + glossary_clip.clone(), + ) + .await + .expect("a final model is configured") + .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", "whisper.dll"); + let model = require_fixture("PROMPTFORGE_WHISPER_MODEL", "ggml-tiny.en.bin"); + let engine = engine(library, model.clone(), Some(model)); + let samples = jfk_samples(); + + let first = engine + .transcribe_final(samples.clone(), Vec::new(), String::new()) + .await + .expect("a final model is configured") + .expect("first job succeeds"); + let second = engine + .transcribe_final(samples, Vec::new(), String::new()) + .await + .expect("a final model is configured") + .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", "whisper.dll"); + let model = require_fixture("PROMPTFORGE_WHISPER_MODEL", "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 + .transcribe_final(prompt_sensitive.clone(), Vec::new(), String::new()) + .await + .expect("a final model is configured") + .expect("control job succeeds"); + let history = engine + .transcribe_final(samples[..4 * 16_000].to_vec(), Vec::new(), String::new()) + .await + .expect("a final model is configured") + .expect("history source succeeds"); + let conditioned = engine + .transcribe_final(prompt_sensitive.clone(), Vec::new(), history) + .await + .expect("a final model is configured") + .expect("conditioned job succeeds"); + assert_ne!(conditioned, control, "fixture detects conditioning"); + + let standalone = engine + .transcribe_final(prompt_sensitive, Vec::new(), String::new()) + .await + .expect("a final model is configured") + .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", "whisper.dll"); + let model = require_fixture("PROMPTFORGE_WHISPER_MODEL", "ggml-tiny.en.bin"); + let engine = engine(library, model, None); + assert!( + engine + .transcribe_final(jfk_samples(), Vec::new(), String::new()) + .await + .is_none(), + "an omitted final model leaves no final decoder" + ); +} + +#[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", "whisper.dll"); + let model = require_fixture("PROMPTFORGE_WHISPER_MODEL", "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 index fe731f35..da5bd1bd 100644 --- a/crates/gateway-stt-engine/AGENTS.md +++ b/crates/gateway-stt-engine/AGENTS.md @@ -1,9 +1,7 @@ # gateway-stt-engine -This crate owns the Whisper transcription engine and nothing else: model ownership, stateless interim and final inference workers, silence gating, and the runtime-loaded gateway-whisper-ffi integration. +This crate owns backend-neutral stateless transcription workers and shared audio policy. -- 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`. -- Decode jobs are stateless. Guidance, finalized history, segmentation, LocalAgreement state, transcript aggregation, completion, and failure belong to `gateway-stt`; workers retain none of them between jobs and have no reset channel. -- 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. +- 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. +- 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 index 3e4eda42..b1ca0360 100644 --- a/crates/gateway-stt-engine/Cargo.toml +++ b/crates/gateway-stt-engine/Cargo.toml @@ -7,30 +7,11 @@ license.workspace = true repository.workspace = true publish = false -description = "PromptForge Whisper transcription engine: model ownership, stateless decode workers, and silence gating" +description = "Backend-neutral PromptForge speech decoding workers and audio policy" [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-stt-engine = { path = ".", features = ["test-fixtures"] } -tempfile.workspace = true -tokio = { workspace = true, features = ["test-util"] } [lints] workspace = true diff --git a/crates/gateway-stt-engine/src/decoder.rs b/crates/gateway-stt-engine/src/decoder.rs new file mode 100644 index 00000000..ecb62fa1 --- /dev/null +++ b/crates/gateway-stt-engine/src/decoder.rs @@ -0,0 +1,42 @@ +//! Backend-neutral model construction and stateless decoding contracts. + +use std::fmt::Debug; + +use crate::TranscribeError; + +/// One backend decoder confined to a transcription worker thread. +/// +/// Every call receives all guidance and finalized history needed for that +/// decode. Implementations must not retain request state between calls. +pub trait Decoder { + /// Decodes one owned worker job. + /// + /// # Errors + /// Returns a backend-translated transcription failure. + fn transcribe( + &mut self, + samples: &[f32], + guidance: &[String], + finalized: &str, + ) -> Result; +} + +/// Constructs backend decoders on the worker threads that own them. +pub trait ModelFactory: Debug + Send + Sync + 'static { + /// Constructs the required interim decoder. + /// + /// # Errors + /// Returns a backend-translated model construction failure. + fn create_interim(&self) -> Result, TranscribeError>; + + /// Constructs the optional final decoder. + /// + /// `None` means final-pass transcription is not configured. + /// + /// # Errors + /// Returns a backend-translated model construction failure. + fn create_final(&self) -> Result>, TranscribeError>; + + /// Whether the loaded backend reports hardware acceleration. + fn gpu_available(&self) -> bool; +} diff --git a/crates/gateway-stt-engine/src/engine.rs b/crates/gateway-stt-engine/src/engine.rs index cdfe633b..71f6acca 100644 --- a/crates/gateway-stt-engine/src/engine.rs +++ b/crates/gateway-stt-engine/src/engine.rs @@ -1,178 +1,97 @@ -//! The STT engine driving the interim and final-pass whisper workers. +//! Backend-neutral interim and final transcription workers. -use std::path::PathBuf; +use std::sync::Arc; 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; +use crate::{ModelFactory, SAMPLE_RATE, TranscribeError}; -/// 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, - /// 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. +/// The STT engine: one required interim worker and one optional final worker. #[derive(Debug)] pub struct SttEngine { transcriber: Transcriber, - final_pass: Option, + 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. + /// Builds backend decoders on their owning worker threads. /// - /// # 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. + /// `window_seconds` and `interval_ms` are backend-neutral capture policy. /// /// # 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, + /// Returns [`TranscribeError::InvalidConfig`] for zero or overflowing + /// policy values, a backend-translated construction failure, or + /// [`TranscribeError::SpawnWorker`] when a worker cannot start. + pub fn new( + factory: impl ModelFactory, + window_seconds: u64, + interval_ms: u64, ) -> Result { - if config.window_seconds == 0 { + if window_seconds == 0 { return Err(TranscribeError::InvalidConfig( - "stt.window_seconds must be at least 1".to_string(), + "stt.window_seconds must be at least 1".to_owned(), )); } - if config.interval_ms == 0 { + if interval_ms == 0 { return Err(TranscribeError::InvalidConfig( - "stt.interval_ms must be at least 1".to_string(), + "stt.interval_ms must be at least 1".to_owned(), )); } - let window_seconds = usize::try_from(config.window_seconds).map_err(|_| { - TranscribeError::InvalidConfig("stt.window_seconds is too large".to_string()) + let seconds = usize::try_from(window_seconds).map_err(|_| { + TranscribeError::InvalidConfig("stt.window_seconds is too large".to_owned()) })?; - 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), - } + let window_samples = seconds.checked_mul(SAMPLE_RATE).ok_or_else(|| { + TranscribeError::InvalidConfig("stt.window_seconds is too large".to_owned()) })?; - // 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 gpu_available = factory.gpu_available(); + let factory: Arc = Arc::new(factory); let (transcriber, interim_init) = - Transcriber::spawn(library.clone(), &config.interim_model, interim_progress)?; - let final_spawned = match &config.final_model { - None => None, - Some(final_model) => Some(FinalTranscriber::spawn( - library, - final_model, - final_progress, - )?), - }; - interim_init + Transcriber::spawn("stt-interim", Arc::clone(&factory), false)?; + let (final_worker, final_init) = + Transcriber::spawn("stt-final", Arc::clone(&factory), true)?; + + let interim_exists = 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) - } + if !interim_exists { + return Err(TranscribeError::InvalidConfig( + "the interim decoder is required".to_owned(), + )); + } + let final_pass = if final_init + .recv() + .map_err(|_| TranscribeError::WorkerGone)?? + { + Some(final_worker) + } else { + None }; + Ok(Self { transcriber, final_pass, gpu_available, window_samples, - interval: Duration::from_millis(config.interval_ms), + interval: Duration::from_millis(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. + /// Whether the final pass is configured. #[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. + /// Whether the backend reports hardware acceleration. #[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 { @@ -185,28 +104,24 @@ impl SttEngine { self.interval } - /// Transcribes one 16 kHz mono f32 buffer, returning the trimmed text. + /// Transcribes one interim audio buffer. /// /// # Errors - /// Returns [`TranscribeError::Inference`] when the model rejects the - /// audio and [`TranscribeError::WorkerGone`] when the worker thread has - /// exited. + /// Returns a decoder failure or [`TranscribeError::WorkerGone`]. pub async fn transcribe( &self, samples: Vec, guidance: Vec, ) -> Result { - self.transcriber.transcribe(samples, guidance).await + self.transcriber + .transcribe(samples, guidance, String::new()) + .await } - /// Transcribes one independent buffer with the final model using only - /// the guidance and finalized history supplied on this job. - /// - /// This request does not read or change the active streaming take. + /// Transcribes one independent buffer with the optional final decoder. /// /// # Errors - /// Returns [`TranscribeError::Inference`] when the model rejects the - /// audio and [`TranscribeError::WorkerGone`] when the worker exits. + /// Returns a decoder failure or [`TranscribeError::WorkerGone`]. pub async fn transcribe_final( &self, samples: Vec, @@ -220,140 +135,247 @@ impl SttEngine { } } -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 std::path::PathBuf; + use std::sync::mpsc; + use std::thread::ThreadId; + + use crate::{Decoder, ModelFactory}; + use super::*; - use crate::fixtures; + #[derive(Debug)] + struct NeverFactory; - #[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(), Vec::new()) - .await - .expect("transcription succeeds"); - assert!( - text.to_lowercase().contains("country"), - "transcript names the fixture's words: {text:?}" - ); + impl ModelFactory for NeverFactory { + fn create_interim(&self) -> Result, TranscribeError> { + panic!("invalid policy must fail before backend construction"); + } + + fn create_final(&self) -> Result>, TranscribeError> { + panic!("invalid policy must fail before backend construction"); + } + + fn gpu_available(&self) -> bool { + false + } } #[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:?}" - ); + fn zero_window_is_rejected_before_backend_construction() { + let error = SttEngine::new(NeverFactory, 0, 500).expect_err("zero window must fail"); + assert!(matches!(error, TranscribeError::InvalidConfig(_))); } #[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}" - ); + fn zero_interval_is_rejected_before_backend_construction() { + let error = SttEngine::new(NeverFactory, 15, 0).expect_err("zero interval must fail"); + assert!(matches!(error, TranscribeError::InvalidConfig(_))); } - #[tokio::test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - async fn final_decode_is_absent_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"); - assert!( - engine - .transcribe_final(fixtures::jfk_samples(), Vec::new(), String::new()) - .await - .is_none(), - "no final model means the caller falls back" - ); + #[derive(Debug)] + struct FailingModelFactory { + created: mpsc::Sender, + } + + impl ModelFactory for FailingModelFactory { + fn create_interim(&self) -> Result, TranscribeError> { + 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"), + )) + } + + fn create_final(&self) -> Result>, TranscribeError> { + Ok(None) + } + + fn gpu_available(&self) -> bool { + false + } } #[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}" + 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, + }, + 15, + 500, + ) + .expect_err("model construction must fail"); + assert!(matches!(error, TranscribeError::LoadModel { .. })); + 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_interim(&self) -> Result, TranscribeError> { + Ok(Box::new(InterimDropProbe { + dropped: self.interim_dropped.clone(), + })) + } + + fn create_final(&self) -> Result>, TranscribeError> { + Err(TranscribeError::InvalidConfig( + FINAL_INIT_SENTINEL.to_owned(), + )) + } + + fn gpu_available(&self) -> bool { + false + } + } + + struct InterimDropProbe { + dropped: mpsc::Sender<()>, + } + + impl Decoder for InterimDropProbe { + fn transcribe( + &mut self, + _samples: &[f32], + _guidance: &[String], + _finalized: &str, + ) -> Result { + Ok(String::new()) + } + } + + impl Drop for InterimDropProbe { + fn drop(&mut self) { + let _ignored = self.dropped.send(()); + } + } + #[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() + 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, + }, + 15, + 500, + ) + .expect_err("final model construction must fail"); + let TranscribeError::InvalidConfig(message) = error else { + panic!("the final worker's exact failure must reach the constructor"); }; - 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}" + assert_eq!(message, 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_interim(&self) -> Result, TranscribeError> { + let owner = std::thread::current().id(); + assert!( + self.events.send(WorkerEvent::Created(owner)).is_ok(), + "the test must receive decoder creation" + ); + Ok(Box::new(FailingDecoder { + owner, + events: self.events.clone(), + })) + } + + fn create_final(&self) -> Result>, TranscribeError> { + Ok(None) + } + + fn gpu_available(&self) -> bool { + false + } + } + + struct FailingDecoder { + owner: ThreadId, + events: mpsc::Sender, + } + + impl Decoder for FailingDecoder { + fn transcribe( + &mut self, + _samples: &[f32], + _guidance: &[String], + _finalized: &str, + ) -> 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 }, 15, 500) + .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 + .transcribe(vec![0.25; SAMPLE_RATE], Vec::new()) + .await + .expect_err("fake decode must fail"); + assert!(matches!(error, TranscribeError::Inference(_))); + 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/src/error.rs b/crates/gateway-stt-engine/src/error.rs index 78b3dca6..2d8b9ec5 100644 --- a/crates/gateway-stt-engine/src/error.rs +++ b/crates/gateway-stt-engine/src/error.rs @@ -1,4 +1,4 @@ -//! STT engine construction and transcription failures. +//! Backend-neutral STT engine construction and transcription failures. use std::path::PathBuf; @@ -6,24 +6,18 @@ use std::path::PathBuf; #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum TranscribeError { - /// The provisioned whisper.cpp shared library could not be loaded. + /// The selected transcription backend could not be initialized. #[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, - }, + #[error("initialize transcription backend")] + InitializeBackend(#[source] Box), - /// The whisper model file could not be loaded. + /// The transcription model file could not be loaded. #[non_exhaustive] - #[error("load whisper model {}", path.display())] + #[error("load transcription model {}", path.display())] LoadModel { /// The model path that failed to load. path: PathBuf, - /// The underlying whisper.cpp error, boxed to hide the dependency. + /// The underlying backend error. #[source] source: Box, }, @@ -33,7 +27,7 @@ pub enum TranscribeError { #[error("spawn transcription worker")] SpawnWorker(#[source] std::io::Error), - /// The model rejected an audio window. + /// The decoder rejected an audio window. #[non_exhaustive] #[error("transcribe audio window")] Inference(#[source] Box), @@ -48,3 +42,26 @@ pub enum TranscribeError { #[error("invalid STT configuration: {0}")] InvalidConfig(String), } + +impl TranscribeError { + /// 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/final_pass.rs b/crates/gateway-stt-engine/src/final_pass.rs deleted file mode 100644 index 9d3360db..00000000 --- a/crates/gateway-stt-engine/src/final_pass.rs +++ /dev/null @@ -1,330 +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}; - -/// Final-model decoder state confined to its worker thread. -/// -/// Guidance and finalized history arrive on every job. The decoder retains -/// no take identity or transcript between jobs. -#[derive(Debug)] -struct FinalDecoder { - ctx: WhisperContext, - state: WhisperState, -} - -impl FinalDecoder { - /// Loads the final model from `path`. - /// - /// # Errors - /// Returns [`TranscribeError::LoadModel`] when the model file cannot be - /// loaded. - fn load( - library: &WhisperLibrary, - path: &Path, - 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), - }; - }; - Ok(Self { ctx, state }) - } - - /// Executes one decode from only the state carried by this job. - /// - /// # Errors - /// Returns [`TranscribeError::Inference`] when the model rejects the - /// audio. - fn transcribe( - &mut self, - samples: &[f32], - guidance: &[String], - finalized: &str, - ) -> Result { - if samples.len() < MIN_WINDOW_SAMPLES || is_silence(samples) { - return Ok(String::new()); - } - let glossary = fit_glossary(&self.ctx, guidance, GLOSSARY_TOKEN_BUDGET); - let prompt = final_prompt(&self.ctx, glossary.as_deref(), finalized); - transcribe_blocking(&mut self.state, samples, Some(&prompt), false) - } -} - -/// A command for the final-pass worker thread. -struct FinalJob { - samples: Vec, - guidance: Vec, - finalized: String, - 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, - 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 worker = std::thread::Builder::new() - .name("whisper-final".to_string()) - .spawn(move || { - final_worker_loop(&library, &path, progress.as_ref(), &job_rx, &init_tx); - }) - .map_err(TranscribeError::SpawnWorker)?; - Ok(( - Self { - job_tx: Some(job_tx), - worker: Some(worker), - }, - init_rx, - )) - } - - /// Executes one independent final-model decode job. - pub(super) async fn transcribe( - &self, - samples: Vec, - guidance: Vec, - finalized: String, - ) -> 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 { - samples, - guidance, - finalized, - 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, - progress: Option<&ProgressHandle>, - job_rx: &std::sync::mpsc::Receiver, - init_tx: &std::sync::mpsc::SyncSender>, -) { - let mut decoder = match FinalDecoder::load(library, path, progress) { - Ok(decoder) => { - let _ = init_tx.send(Ok(())); - decoder - } - Err(error) => { - let _ = init_tx.send(Err(error)); - return; - } - }; - while let Ok(job) = job_rx.recv() { - let result = decoder.transcribe(&job.samples, &job.guidance, &job.finalized); - if let Err(error) = &result { - tracing::warn!(%error, "final-model transcription failed"); - } - let _ = job.reply.send(result); - } -} - -#[cfg(test)] -mod tests { - 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 decoder = FinalDecoder::load(&library, &fixtures::require_model(), None) - .expect("final decoder loads the fixture model"); - let first = decoder - .transcribe(&fixtures::jfk_samples(), &vocabulary, "") - .expect("segment one transcribes"); - assert!( - first.to_lowercase().contains("country"), - "segment one names the fixture's words: {first:?}" - ); - let second = decoder - .transcribe(&fixtures::jfk_samples(), &vocabulary, &first) - .expect("segment two transcribes"); - assert!( - second.to_lowercase().contains("country"), - "segment two names the fixture's words: {second:?}" - ); - } - - #[tokio::test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - async fn final_worker_returns_each_stateless_decode_to_its_caller() { - 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 text = engine - .transcribe_final(fixtures::jfk_samples(), Vec::new(), String::new()) - .await - .expect("a final model is configured") - .expect("the final decode succeeds"); - assert!( - text.to_lowercase().contains("country"), - "the decode names the fixture's words: {text:?}" - ); - } - - #[tokio::test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - async fn a_silent_final_job_returns_empty() { - 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 text = engine - .transcribe_final(vec![0.0; SAMPLE_RATE], Vec::new(), String::new()) - .await - .expect("a final model is configured") - .expect("the final decode succeeds"); - assert!(text.is_empty(), "silence is skipped, not transcribed"); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn final_decoder_uses_only_the_history_supplied_on_each_job() { - let library = fixtures::require_loaded_library(); - let mut decoder = FinalDecoder::load(&library, &fixtures::require_model(), None) - .expect("final decoder loads the fixture model"); - let jfk = fixtures::jfk_samples(); - - let first = decoder - .transcribe(&jfk, &[], "") - .expect("segment one transcribes"); - let first_lower = first.to_lowercase(); - assert!( - first_lower.contains("country"), - "segment one names the fixture's words: {first:?}" - ); - let second = decoder - .transcribe(&jfk, &[], &first) - .expect("segment two transcribes"); - assert!( - second.to_lowercase().contains("country"), - "the segment's own text names the fixture's words: {second:?}" - ); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn independent_final_jobs_do_not_require_a_reset() { - let library = fixtures::require_loaded_library(); - let mut decoder = FinalDecoder::load(&library, &fixtures::require_model(), None) - .expect("final decoder loads the fixture model"); - let jfk = fixtures::jfk_samples(); - - let first = decoder.transcribe(&jfk, &[], "").expect("first job"); - let second = decoder.transcribe(&jfk, &[], "").expect("second job"); - assert_eq!(second, first, "jobs with equal inputs are independent"); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn one_final_job_cannot_change_another_jobs_history() { - let library = fixtures::require_loaded_library(); - let mut decoder = FinalDecoder::load(&library, &fixtures::require_model(), None) - .expect("final decoder loads the fixture model"); - let jfk = fixtures::jfk_samples(); - let prompt_sensitive = &jfk[6 * SAMPLE_RATE..8 * SAMPLE_RATE]; - let control = decoder - .transcribe(prompt_sensitive, &[], "") - .expect("preconditioned control job"); - let history = decoder - .transcribe(&jfk[..4 * SAMPLE_RATE], &[], "") - .expect("history source job"); - let conditioned = decoder - .transcribe(prompt_sensitive, &[], &history) - .expect("conditioned job"); - assert_ne!( - conditioned, control, - "the fixture must detect transcript conditioning" - ); - let standalone = decoder - .transcribe(prompt_sensitive, &[], "") - .expect("post-conditioned independent job"); - assert_eq!( - standalone, control, - "a prior job's history cannot leak into a stateless decode" - ); - } - - #[test] - #[ignore = "requires whisper test fixtures (tests/fixtures/)"] - fn final_decoder_skips_silence() { - let library = fixtures::require_loaded_library(); - let mut decoder = FinalDecoder::load(&library, &fixtures::require_model(), None) - .expect("final decoder loads the fixture model"); - let text = decoder - .transcribe(&vec![0.0; SAMPLE_RATE * 2], &[], "history") - .expect("silence is skipped, not an error"); - assert!(text.is_empty(), "silence transcribes to nothing"); - } -} diff --git a/crates/gateway-stt-engine/src/lib.rs b/crates/gateway-stt-engine/src/lib.rs index 71650b24..8f5e70b0 100644 --- a/crates/gateway-stt-engine/src/lib.rs +++ b/crates/gateway-stt-engine/src/lib.rs @@ -1,235 +1,16 @@ -//! Whisper transcription on dedicated worker threads. +//! Backend-neutral speech decoding 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 executes independent decode jobs. Callers -//! hand owned samples, guidance, and finalized history through channels and -//! await transcripts on oneshots, so blocking 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. +//! [`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 final_pass; -mod prompt; -mod slot; +mod policy; mod worker; -pub use engine::{EngineConfig, SttEngine}; +pub use decoder::{Decoder, ModelFactory}; +pub use engine::SttEngine; pub use error::TranscribeError; -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]); - } -} +pub use policy::{MIN_WINDOW_SAMPLES, SAMPLE_RATE, is_silence}; diff --git a/crates/gateway-stt-engine/src/policy.rs b/crates/gateway-stt-engine/src/policy.rs new file mode 100644 index 00000000..2ab5b9fe --- /dev/null +++ b/crates/gateway-stt-engine/src/policy.rs @@ -0,0 +1,52 @@ +//! Backend-neutral audio policy shared by the engine and its host. + +/// 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 = SAMPLE_RATE / 2; + +/// Windows below this RMS are treated as silence. +const SILENCE_RMS: f64 = 0.001; + +#[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 a speech decoder would +/// hallucinate rather than transcribe. +#[must_use] +pub fn is_silence(samples: &[f32]) -> bool { + rms(samples) < SILENCE_RMS +} + +#[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])); + } +} diff --git a/crates/gateway-stt-engine/src/prompt.rs b/crates/gateway-stt-engine/src/prompt.rs deleted file mode 100644 index 2e762cc6..00000000 --- a/crates/gateway-stt-engine/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-stt-engine/src/slot.rs b/crates/gateway-stt-engine/src/slot.rs deleted file mode 100644 index d3bd771f..00000000 --- a/crates/gateway-stt-engine/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-stt-engine/src/worker.rs b/crates/gateway-stt-engine/src/worker.rs index 7ab453a2..077c8899 100644 --- a/crates/gateway-stt-engine/src/worker.rs +++ b/crates/gateway-stt-engine/src/worker.rs @@ -1,29 +1,17 @@ -//! The interim whisper worker thread and the shared blocking inference pass. +//! One backend-neutral transcription worker. -use std::io::Read; -use std::path::Path; +use std::sync::Arc; -use gateway_whisper_ffi::{ - FullParams, SamplingStrategy, WhisperContext, WhisperLibrary, WhisperState, -}; -use shared_progress::ProgressHandle; +use crate::{Decoder, ModelFactory, TranscribeError}; -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, guidance: Vec, + finalized: String, reply: tokio::sync::oneshot::Sender>, } -/// Handle to the whisper worker thread. +/// Handle to a decoder confined to its worker thread. #[derive(Debug)] pub(crate) struct Transcriber { job_tx: Option>, @@ -31,28 +19,23 @@ pub(crate) struct Transcriber { } 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`]. + /// Spawns one worker and reports whether its optional decoder exists. pub(super) fn spawn( - library: WhisperLibrary, - model_path: &Path, - progress: Option, - ) -> Result<(Self, std::sync::mpsc::Receiver>), TranscribeError> - { + name: &'static str, + factory: Arc, + final_model: bool, + ) -> 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 worker = std::thread::Builder::new() - .name("whisper-transcribe".to_string()) - .spawn(move || { - worker_loop(&library, &path, progress.as_ref(), &job_rx, &init_tx); - }) + .name(name.to_owned()) + .spawn(move || worker_loop(factory.as_ref(), final_model, &job_rx, &init_tx)) .map_err(TranscribeError::SpawnWorker)?; Ok(( Self { @@ -63,11 +46,11 @@ impl Transcriber { )) } - /// Queues one independent decode and awaits the trimmed text. pub(super) async fn transcribe( &self, samples: Vec, guidance: Vec, + finalized: String, ) -> Result { let (reply, reply_rx) = tokio::sync::oneshot::channel(); let Some(job_tx) = &self.job_tx else { @@ -77,6 +60,7 @@ impl Transcriber { .send(Job { samples, guidance, + finalized, reply, }) .map_err(|_| TranscribeError::WorkerGone)?; @@ -86,8 +70,6 @@ impl Transcriber { 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(); @@ -95,222 +77,39 @@ impl Drop for Transcriber { } } -/// The worker thread's body: load the model, then execute independent jobs -/// in arrival order until every sender is dropped. fn worker_loop( - library: &WhisperLibrary, - path: &Path, - progress: Option<&ProgressHandle>, + factory: &dyn ModelFactory, + final_model: bool, job_rx: &std::sync::mpsc::Receiver, - init_tx: &std::sync::mpsc::SyncSender>, + init_tx: &std::sync::mpsc::SyncSender>, ) { - let Some((ctx, mut state)) = load_state(library, path, progress, init_tx) else { - return; + let decoder = if final_model { + factory.create_final() + } else { + factory.create_interim().map(Some) }; - while let Ok(job) = job_rx.recv() { - // The interim pass carries no history, so this job's guidance gets - // the full prompt budget. - let glossary = fit_glossary(&ctx, &job.guidance, MAX_PROMPT_TOKENS); - // 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) + let Some(mut decoder): Option> = (match decoder { + Ok(decoder) => { + if init_tx.send(Ok(decoder.is_some())).is_err() { + return; + } + decoder } Err(error) => { - let _ = init_tx.send(Err(error)); - None + // Initialization failure is terminal, and cancellation leaves no + // engine constructor to receive it. + match init_tx.send(Err(error)) { + Ok(()) | Err(_) => return, + } } - } -} - -/// 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), + }) else { + return; }; - 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)))?; + while let Ok(job) = job_rx.recv() { + let result = decoder.transcribe(&job.samples, &job.guidance, &job.finalized); + if job.reply.send(result).is_err() { + // A canceled caller abandons only its reply; the stateless worker + // remains available for later jobs. } } - 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-stt-engine/tests/native_whisper.rs b/crates/gateway-stt-engine/tests/native_whisper.rs deleted file mode 100644 index 1156284a..00000000 --- a/crates/gateway-stt-engine/tests/native_whisper.rs +++ /dev/null @@ -1,114 +0,0 @@ -//! Native characterization of the packaged Whisper runtime contract. - -use gateway_stt_engine::{EngineConfig, SttEngine, fixtures}; - -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; - -#[tokio::test] -#[ignore = "requires whisper test fixtures (tests/fixtures/)"] -async fn packaged_runtime_preserves_native_transcription_contract() { - let temp = tempfile::tempdir().expect("temporary packaged-runtime directory"); - let library = fixtures::require_library(); - let model = temp.path().join("ggml-tiny.en.bin"); - std::fs::copy(fixtures::require_model(), &model).expect("copy the exact tiny model fixture"); - let samples = fixtures::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 = SttEngine::new(&EngineConfig { - library: library.clone(), - interim_model: model.clone(), - final_model: Some(model.clone()), - window_seconds: 12, - interval_ms: 500, - }) - .expect("packaged runtime and model load"); - - let interim = unprompted - .transcribe(samples.clone(), Vec::new()) - .await - .expect("interim decode succeeds"); - assert_eq!(interim, JFK_TRANSCRIPT, "interim decode policy stays fixed"); - - let unprompted_clip = unprompted - .transcribe_final(prompt_sensitive_clip.clone(), Vec::new(), String::new()) - .await - .expect("a final model is configured") - .expect("unprompted final decode succeeds"); - assert_eq!( - unprompted_clip, UNPROMPTED_CLIP_TRANSCRIPT, - "the prompt-sensitive clip has a fixed unprompted control" - ); - - let conditioning_transcript = unprompted - .transcribe_final(conditioning_clip, Vec::new(), String::new()) - .await - .expect("a final model is configured") - .expect("conditioning decode succeeds"); - let conditioned_clip = unprompted - .transcribe_final( - prompt_sensitive_clip.clone(), - Vec::new(), - conditioning_transcript.clone(), - ) - .await - .expect("a final model is configured") - .expect("transcript-conditioned final decode succeeds"); - assert_eq!( - conditioning_transcript, CONDITIONING_TRANSCRIPT, - "the accumulated transcript that conditions the tail stays fixed" - ); - assert_eq!( - conditioned_clip, CONDITIONED_CLIP_TRANSCRIPT, - "the accumulated transcript changes the prompt-sensitive tail" - ); - assert_ne!( - conditioned_clip, unprompted_clip, - "removing accumulated-transcript conditioning must fail this target" - ); - - let glossary_prompted = SttEngine::new(&EngineConfig { - library, - interim_model: model.clone(), - final_model: Some(model.clone()), - window_seconds: 12, - interval_ms: 500, - }) - .expect("glossary-prompted engine loads"); - let glossary_clip = glossary_prompted - .transcribe_final( - prompt_sensitive_clip, - vec!["one tree".to_string()], - String::new(), - ) - .await - .expect("a final model is configured") - .expect("the glossary-conditioned segment decodes"); - let silent_tail = glossary_prompted - .transcribe_final( - vec![0.0; 16_000], - vec!["one tree".to_string()], - glossary_clip.clone(), - ) - .await - .expect("a final model is configured") - .expect("the silent tail decodes"); - assert!(silent_tail.is_empty(), "silence remains gated"); - assert_eq!( - glossary_clip, GLOSSARY_CLIP_TRANSCRIPT, - "the glossary changes the prompt-sensitive segment" - ); - assert_ne!( - glossary_clip, unprompted_clip, - "removing glossary conditioning must fail this target" - ); - - drop(glossary_prompted); - drop(unprompted); - std::fs::remove_file(model).expect("dropping the engine releases the model"); -} diff --git a/crates/gateway-stt/AGENTS.md b/crates/gateway-stt/AGENTS.md index d5ecf48b..c670d6d7 100644 --- a/crates/gateway-stt/AGENTS.md +++ b/crates/gateway-stt/AGENTS.md @@ -1,9 +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-stt-engine`; artifact download and verification stay in `gateway-local::artifacts::ArtifactStore`. - `take::Take` solely owns per-take guidance, finalized history, segmentation, LocalAgreement state, transcript aggregation, completion, and failure. -- 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-stt-engine` as a path. Native backends are never Cargo features. +- Artifact download and verification stay in `gateway-local::artifacts::ArtifactStore`. - `/stt` keeps its existing wire path and frame contract. OpenAI multipart input is capped at 25 MiB before decode. diff --git a/crates/gateway-stt/Cargo.toml b/crates/gateway-stt/Cargo.toml index b7668510..33009331 100644 --- a/crates/gateway-stt/Cargo.toml +++ b/crates/gateway-stt/Cargo.toml @@ -16,6 +16,7 @@ hound.workspace = true gateway-config.workspace = true gateway-local.workspace = true shared-progress.workspace = true +gateway-stt-backend-whisper.workspace = true gateway-stt-engine.workspace = true workshop-server.workspace = true serde.workspace = true @@ -24,12 +25,7 @@ thiserror.workspace = true tokio.workspace = true tracing.workspace = true -[features] -default = [] -test-fixtures = ["gateway-stt-engine/test-fixtures"] - [dev-dependencies] -gateway-stt = { path = ".", features = ["test-fixtures"] } sha2.workspace = true tempfile.workspace = true tokio-tungstenite.workspace = true diff --git a/crates/gateway-stt/src/api.rs b/crates/gateway-stt/src/api.rs index 196feed6..6289d545 100644 --- a/crates/gateway-stt/src/api.rs +++ b/crates/gateway-stt/src/api.rs @@ -564,7 +564,7 @@ mod tests { #[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_stt_engine::fixtures::require_model() + let source = crate::test_fixtures::require_model() .display() .to_string() .replace('\\', "/"); @@ -584,7 +584,7 @@ mod tests { .expect("profile selects"); let state = SttState::default(); let runtime = crate::SttRuntime::start(&config, state.clone(), None).expect("engine loads"); - let samples = gateway_stt_engine::fixtures::jfk_samples(); + let samples = crate::test_fixtures::jfk_samples(); let (boundary, body) = multipart_body( &wav_f32(&samples), &[ diff --git a/crates/gateway-stt/src/lib.rs b/crates/gateway-stt/src/lib.rs index 78da4ebd..0ad651b3 100644 --- a/crates/gateway-stt/src/lib.rs +++ b/crates/gateway-stt/src/lib.rs @@ -12,6 +12,8 @@ mod runtime; mod segment; mod stt; mod take; +#[cfg(test)] +mod test_fixtures; pub use api::{MAX_AUDIO_BYTES, TranscriptionError, transcribe}; pub use runtime::{SttRuntime, SttRuntimeError, SttState}; diff --git a/crates/gateway-stt/src/runtime.rs b/crates/gateway-stt/src/runtime.rs index fccbde89..febb644c 100644 --- a/crates/gateway-stt/src/runtime.rs +++ b/crates/gateway-stt/src/runtime.rs @@ -3,9 +3,10 @@ use std::path::PathBuf; use std::sync::{Arc, PoisonError, RwLock}; -use gateway_config::{Config, SttRole, WorkshopSttConfig}; +use gateway_config::{Config, SttRole}; use gateway_local::artifacts::ArtifactStore; -use gateway_stt_engine::{EngineConfig, SttEngine, SttSlot}; +use gateway_stt_backend_whisper::{WhisperConfig, WhisperModelFactory}; +use gateway_stt_engine::SttEngine; use shared_progress::ProgressHandle; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -21,6 +22,38 @@ struct LoadedNames { guidance: Vec, } +#[derive(Debug, Clone, Default)] +struct SttSlot { + engine: Arc>>>, +} + +impl SttSlot { + fn engine(&self) -> Option> { + self.engine + .read() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + + fn is_active(&self) -> bool { + self.engine + .read() + .unwrap_or_else(PoisonError::into_inner) + .is_some() + } + + fn activate(&self, engine: SttEngine) { + *self.engine.write().unwrap_or_else(PoisonError::into_inner) = Some(Arc::new(engine)); + } + + fn take(&self) -> Option> { + self.engine + .write() + .unwrap_or_else(PoisonError::into_inner) + .take() + } +} + /// Shared active STT state used by both gateway HTTP surfaces. /// /// Clones observe the same engine and loaded-model names across profile @@ -165,13 +198,15 @@ impl SttRuntime { .cloned() .unwrap_or_default(); let guidance = capture.vocabulary().to_vec(); - let engine_config = - engine_config(&capture, library, interim_path, models.final_model.as_ref()); - let engine = SttEngine::new_with_progress( - &engine_config, + let backend_config = WhisperConfig::new( + library, + interim_path, + models.final_model.as_ref().map(|(_, path)| path.clone()), progress.map(|handle| handle.child("engine", 1.0)), - ) - .map_err(SttRuntimeError::Engine)?; + ); + let factory = WhisperModelFactory::new(backend_config).map_err(SttRuntimeError::Engine)?; + let engine = SttEngine::new(factory, capture.window_seconds(), capture.interval_ms()) + .map_err(SttRuntimeError::Engine)?; let final_name = models.final_model.map(|(name, _)| name); state.activate(engine, interim_name, final_name, guidance); Ok(SttRuntime { @@ -247,21 +282,6 @@ fn provision_models( 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()), - window_seconds: capture.window_seconds(), - interval_ms: capture.interval_ms(), - } -} - /// An STT runtime startup failure. #[derive(Debug, thiserror::Error)] #[non_exhaustive] @@ -390,7 +410,7 @@ mod tests { #[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_stt_engine::fixtures::require_model() + let source = crate::test_fixtures::require_model() .display() .to_string() .replace('\\', "/"); diff --git a/crates/gateway-stt/src/take.rs b/crates/gateway-stt/src/take.rs index 0dda06ad..45206dd7 100644 --- a/crates/gateway-stt/src/take.rs +++ b/crates/gateway-stt/src/take.rs @@ -3,7 +3,7 @@ use std::future::Future; use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; -use gateway_stt_engine::{SttEngine, TranscribeError, tail}; +use gateway_stt_engine::{SttEngine, TranscribeError}; use tokio::sync::{mpsc, oneshot}; use crate::segment::Segmenter; @@ -85,6 +85,10 @@ fn append_transcript(text: &mut String, piece: &str) { text.push_str(piece); } +fn tail(buffer: &[f32], window: usize) -> &[f32] { + &buffer[buffer.len().saturating_sub(window)..] +} + #[derive(Debug, Default)] struct FinalizedState { text: String, @@ -412,6 +416,14 @@ mod tests { use super::{FinalCommand, LocalAgreement, Take, run_final_pipeline}; + #[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 local_agreement_requires_two_hypotheses_and_preserves_whitespace() { let mut agreement = LocalAgreement::default(); diff --git a/crates/gateway-stt/src/test_fixtures.rs b/crates/gateway-stt/src/test_fixtures.rs new file mode 100644 index 00000000..9bc4fea4 --- /dev/null +++ b/crates/gateway-stt/src/test_fixtures.rs @@ -0,0 +1,37 @@ +//! Native fixtures used only by this crate's unit tests. + +use std::path::{Path, PathBuf}; + +pub(crate) fn require_model() -> PathBuf { + require_fixture("PROMPTFORGE_WHISPER_MODEL", "ggml-tiny.en.bin") +} + +pub(crate) fn jfk_samples() -> Vec { + let path = require_fixture("PROMPTFORGE_WHISPER_AUDIO", "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 require_fixture(variable: &str, fallback: &str) -> PathBuf { + let path = std::env::var_os(variable).map_or_else( + || { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../gateway-stt-backend-whisper/tests/fixtures") + .join(fallback) + }, + PathBuf::from, + ); + assert!( + path.is_file(), + "native test fixture is missing: {}", + path.display() + ); + path +} diff --git a/crates/gateway-stt/tests/common/mod.rs b/crates/gateway-stt/tests/common/mod.rs index bbb8a445..e8e4e9d6 100644 --- a/crates/gateway-stt/tests/common/mod.rs +++ b/crates/gateway-stt/tests/common/mod.rs @@ -23,8 +23,42 @@ use tower::ServiceExt as _; pub(crate) const RECV_TIMEOUT: Duration = Duration::from_secs(10); const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(30); +pub(crate) fn require_model() -> PathBuf { + require_fixture("PROMPTFORGE_WHISPER_MODEL", "ggml-tiny.en.bin") +} + +pub(crate) fn jfk_samples() -> Vec { + let path = require_fixture("PROMPTFORGE_WHISPER_AUDIO", "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 require_fixture(variable: &str, fallback: &str) -> PathBuf { + let path = std::env::var_os(variable).map_or_else( + || { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../gateway-stt-backend-whisper/tests/fixtures") + .join(fallback) + }, + PathBuf::from, + ); + assert!( + path.is_file(), + "native test fixture is missing: {}", + path.display() + ); + path +} + pub(crate) fn fixture_runtime(with_final: bool) -> (SttState, SttRuntime) { - let source = gateway_stt_engine::fixtures::require_model(); + let source = require_model(); fixture_runtime_with_models(&source, with_final.then_some(source.as_path())) } diff --git a/crates/gateway-stt/tests/it/batch.rs b/crates/gateway-stt/tests/it/batch.rs index 630f72a4..d159ab0a 100644 --- a/crates/gateway-stt/tests/it/batch.rs +++ b/crates/gateway-stt/tests/it/batch.rs @@ -1,14 +1,16 @@ //! Characterization tests for physical-model batch transcription. use axum::http::StatusCode; -use gateway_stt_engine::fixtures::jfk_samples; -use crate::common::{copy_model_replacing_token, fixture_runtime_with_models, transcribe_batch}; +use crate::common::{ + copy_model_replacing_token, fixture_runtime_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 = gateway_stt_engine::fixtures::require_model(); + 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"); diff --git a/crates/gateway-stt/tests/it/legacy_stream.rs b/crates/gateway-stt/tests/it/legacy_stream.rs index bbc86703..b19f420a 100644 --- a/crates/gateway-stt/tests/it/legacy_stream.rs +++ b/crates/gateway-stt/tests/it/legacy_stream.rs @@ -9,7 +9,6 @@ use std::time::Duration; use futures_util::{SinkExt as _, StreamExt as _}; use gateway_stt::Segmenter; -use gateway_stt_engine::fixtures::jfk_samples; use gateway_stt_engine::{MIN_WINDOW_SAMPLES, SAMPLE_RATE}; use serde_json::json; use tokio_tungstenite::tungstenite; @@ -17,8 +16,8 @@ use tokio_tungstenite::tungstenite::client::IntoClientRequest; use crate::common::{ JsonSocket, TestServer, copy_model_replacing_token, fixture_runtime, - fixture_runtime_with_models, fixture_server, send_pcm, send_samples, send_samples_once, - transcribe_batch, + fixture_runtime_with_models, fixture_server, jfk_samples, require_model, send_pcm, + send_samples, send_samples_once, transcribe_batch, }; #[test] @@ -387,7 +386,7 @@ async fn wait_for_committed(socket: &mut JsonSocket, expected_word: &str) -> Str #[tokio::test] #[ignore = "requires whisper test fixtures (tests/fixtures/)"] async fn final_model_segments_and_tail_are_authoritative_at_stop() { - let interim_model = gateway_stt_engine::fixtures::require_model(); + 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"); diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 9609669b..a10d0cb7 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -376,7 +376,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### Step 6: Extract contracts and safe backend atomically - 5acbd7ed - 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index ef964075..2d9f491d 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -134,5 +134,11 @@ N5 | observation | shared-mutable-state @ crates/gateway-stt/src/take.rs::TakeSt N6 | observation | oversized-unit @ crates/gateway-stt/src/take.rs: adds a 651-line take module | Move take ownership into gateway STT 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 -N9 | observation | Violates A2 @ crates/gateway-stt/src/runtime.rs: not determinable from diff | Move take ownership into gateway STT +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 N10 | observation | Violates A96 @ crates/gateway-stt/src/api.rs: not determinable from diff | Move take ownership into gateway STT +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 +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 +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 From b8caab43a3895514616f8ed88c721527ad31482b Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 00:47:18 -0700 Subject: [PATCH 14/86] Enforce exact STT architecture ratchets Add mandatory checks for workspace edges, module cycles, public exports, source size, migration targets, and unsafe isolation. Split compiler-resolved checks from strict policy checks and run both in the normal continuous integration path. - `parseCargoModulesDot` is a 110-line parser that collapses item edges into module edges and rejects malformed graph output. - `module-ceilings.toml` files set strict source and public-root ceilings and name each planned migration target. - `architecture` runs with pinned tool versions in the normal continuous integration job. Design: new global-state @ crates/gateway-stt/tests/it/architecture.rs::workspace_metadata Design: new oversized-unit @ tools/check-stt-architecture.mjs::parseCargoModulesDot deps: output Violates: A2 - not determinable from diff Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- .github/workflows/ci.yml | 14 + Cargo.lock | 1 + .../module-ceilings.toml | 13 + .../gateway-stt-engine/module-ceilings.toml | 21 + crates/gateway-stt/Cargo.toml | 1 + crates/gateway-stt/module-ceilings.toml | 30 + crates/gateway-stt/tests/it/architecture.rs | 573 ++++++++++++++++++ crates/gateway-stt/tests/it/main.rs | 1 + .../gateway-whisper-ffi/module-ceilings.toml | 16 + tools/check-stt-architecture.mjs | 308 ++++++++++ tools/check-stt-architecture.test.mjs | 96 +++ vibe/2026-09-05-2-generic-realtime-stt.md | 30 +- vibe/archdoc-next.md | 1 + 13 files changed, 1100 insertions(+), 5 deletions(-) create mode 100644 crates/gateway-stt-backend-whisper/module-ceilings.toml create mode 100644 crates/gateway-stt-engine/module-ceilings.toml create mode 100644 crates/gateway-stt/module-ceilings.toml create mode 100644 crates/gateway-stt/tests/it/architecture.rs create mode 100644 crates/gateway-whisper-ffi/module-ceilings.toml create mode 100644 tools/check-stt-architecture.mjs create mode 100644 tools/check-stt-architecture.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d394e9e6..7cda4d46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,11 @@ jobs: with: node-version: 22 + - name: Install architecture tools + run: | + cargo install cargo-modules --version 0.25.0 --locked + cargo install cargo-public-api --version 0.52.0 --locked + - name: Install UI dependencies working-directory: crates/workshop-server/ui run: npm ci @@ -49,6 +54,15 @@ jobs: working-directory: crates/gateway-config-ui/ui run: npm ci + - 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 STT Cargo and ceiling architecture + run: cargo test -p gateway-stt --test it architecture + - name: Format run: cargo fmt --all --check diff --git a/Cargo.lock b/Cargo.lock index e17868db..f0c5aafe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2010,6 +2010,7 @@ dependencies = [ "thiserror 2.0.19", "tokio", "tokio-tungstenite", + "toml 0.8.2", "tower", "tracing", "workshop-server", 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..ec393863 --- /dev/null +++ b/crates/gateway-stt-backend-whisper/module-ceilings.toml @@ -0,0 +1,13 @@ +# Exact source and public-root ratchets for the safe Whisper backend. +# Physical lines include comments and blanks. A source file may shrink but +# may not exceed its recorded ceiling. + +public_root_budget = 2 + +[migration_targets] + +[modules] +"config.rs" = 32 +"lib.rs" = 8 +"model.rs" = 301 +"prompt.rs" = 233 diff --git a/crates/gateway-stt-engine/module-ceilings.toml b/crates/gateway-stt-engine/module-ceilings.toml new file mode 100644 index 00000000..18256f81 --- /dev/null +++ b/crates/gateway-stt-engine/module-ceilings.toml @@ -0,0 +1,21 @@ +# Exact source and public-root ratchets for the backend-neutral STT engine. +# Physical lines include comments and blanks. A source file may shrink but +# may not exceed its recorded ceiling. + +public_root_budget = 7 + +[migration_targets."engine.rs"] +target_step = "Step 8" +destination = "bounded worker dispatch" + +[migration_targets."worker.rs"] +target_step = "Step 8" +destination = "bounded worker command queues" + +[modules] +"decoder.rs" = 42 +"engine.rs" = 381 +"error.rs" = 67 +"lib.rs" = 16 +"policy.rs" = 52 +"worker.rs" = 115 diff --git a/crates/gateway-stt/Cargo.toml b/crates/gateway-stt/Cargo.toml index 33009331..74bfd712 100644 --- a/crates/gateway-stt/Cargo.toml +++ b/crates/gateway-stt/Cargo.toml @@ -29,6 +29,7 @@ tracing.workspace = true sha2.workspace = true tempfile.workspace = true tokio-tungstenite.workspace = true +toml.workspace = true tower.workspace = true [lints] diff --git a/crates/gateway-stt/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml new file mode 100644 index 00000000..afa4b199 --- /dev/null +++ b/crates/gateway-stt/module-ceilings.toml @@ -0,0 +1,30 @@ +# Exact source and public-root ratchets for the gateway STT facade. +# Physical lines include comments and blanks. A source file may shrink but +# may not exceed its recorded ceiling. + +public_root_budget = 9 + +[migration_targets."api.rs"] +target_step = "Step 15" +destination = "batch.rs" + +[migration_targets."runtime.rs"] +target_step = "Step 15" +destination = "service.rs, artifacts.rs, generation.rs, status.rs, and model.rs" + +[migration_targets."stt.rs"] +target_step = "Step 26" +destination = "removal after the Realtime route and Workshop relay replace the legacy socket" + +[migration_targets."take.rs"] +target_step = "Step 14" +destination = "independent committed-item finalization" + +[modules] +"api.rs" = 625 +"lib.rs" = 21 +"runtime.rs" = 439 +"segment.rs" = 233 +"stt.rs" = 712 +"take.rs" = 663 +"test_fixtures.rs" = 37 diff --git a/crates/gateway-stt/tests/it/architecture.rs b/crates/gateway-stt/tests/it/architecture.rs new file mode 100644 index 00000000..88d2963f --- /dev/null +++ b/crates/gateway-stt/tests/it/architecture.rs @@ -0,0 +1,573 @@ +//! 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 PHASE_A_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], + temporary_edges: &'static [TemporaryEdge], +} + +struct TemporaryEdge { + dependency: &'static str, + removal_step: &'static str, +} + +struct MigrationPolicy { + crate_name: &'static str, + targets: &'static [MigrationPolicyTarget], +} + +struct MigrationPolicyTarget { + module: &'static str, + target_step: &'static str, + destination: &'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-web-search", + "promptforge-core", + "shared-loopback", + "shared-progress", + "shared-protocol", + "shared-sidecar", + ], + temporary_edges: &[], + }, + DependencyPolicy { + crate_name: "gateway-stt", + final_edges: &[ + "gateway-config", + "gateway-local", + "gateway-stt-backend-whisper", + "gateway-stt-engine", + "shared-progress", + ], + temporary_edges: &[TemporaryEdge { + dependency: "workshop-server", + removal_step: "Step 26", + }], + }, + DependencyPolicy { + crate_name: "gateway-stt-engine", + final_edges: &[], + temporary_edges: &[], + }, + DependencyPolicy { + crate_name: "gateway-stt-backend-whisper", + final_edges: &[ + "gateway-stt-engine", + "gateway-whisper-ffi", + "shared-progress", + ], + temporary_edges: &[], + }, + DependencyPolicy { + crate_name: "gateway-whisper-ffi", + final_edges: &[], + temporary_edges: &[], + }, + DependencyPolicy { + crate_name: "shared-loopback", + final_edges: &[], + temporary_edges: &[], + }, + DependencyPolicy { + crate_name: "workshop-server", + final_edges: &[ + "build-ui", + "promptforge-agent", + "promptforge-core-support", + "promptforge-model-client", + "promptforge-store", + "promptforge-tools", + "shared-progress", + "shared-sidecar", + ], + temporary_edges: &[], + }, +]; + +const MIGRATION_POLICIES: [MigrationPolicy; 4] = [ + MigrationPolicy { + crate_name: "gateway-stt", + targets: &[ + MigrationPolicyTarget { + module: "api.rs", + target_step: "Step 15", + destination: "batch.rs", + }, + MigrationPolicyTarget { + module: "runtime.rs", + target_step: "Step 15", + destination: "service.rs, artifacts.rs, generation.rs, status.rs, and model.rs", + }, + MigrationPolicyTarget { + module: "stt.rs", + target_step: "Step 26", + destination: "removal after the Realtime route and Workshop relay replace the legacy socket", + }, + MigrationPolicyTarget { + module: "take.rs", + target_step: "Step 14", + destination: "independent committed-item finalization", + }, + ], + }, + MigrationPolicy { + crate_name: "gateway-stt-engine", + targets: &[ + MigrationPolicyTarget { + module: "engine.rs", + target_step: "Step 8", + destination: "bounded worker dispatch", + }, + MigrationPolicyTarget { + module: "worker.rs", + target_step: "Step 8", + destination: "bounded worker command queues", + }, + ], + }, + MigrationPolicy { + crate_name: "gateway-stt-backend-whisper", + targets: &[], + }, + MigrationPolicy { + crate_name: "gateway-whisper-ffi", + targets: &[], + }, +]; + +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct CeilingsFile { + public_root_budget: usize, + migration_targets: BTreeMap, + modules: BTreeMap, +} + +#[derive(Debug, Eq, PartialEq, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct MigrationTarget { + target_step: String, + destination: String, +} + +#[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 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 = PHASE_A_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 Phase A crates: expected {expected:?}, got {actual:?}" + )); + } + Ok(()) +} + +#[test] +fn workspace_dependencies_match_phase_specific_allowlists() { + let metadata = workspace_metadata(); + validate_dependency_policies(&DEPENDENCY_POLICIES).unwrap_or_else(|error| panic!("{error}")); + for policy in &DEPENDENCY_POLICIES { + let mut allowed = policy + .final_edges + .iter() + .copied() + .map(str::to_owned) + .collect::>(); + for edge in policy.temporary_edges { + assert!( + edge.removal_step.starts_with("Step "), + "{} -> {} must name its removal step", + policy.crate_name, + edge.dependency + ); + allowed.insert(edge.dependency.to_owned()); + } + assert_eq!( + crate_workspace_edges(metadata, policy.crate_name), + allowed, + "{} workspace edges drifted from the current phase allowlist", + policy.crate_name + ); + } +} + +#[test] +fn dependency_policy_omission_is_rejected() { + assert!( + validate_dependency_policies(&DEPENDENCY_POLICIES[..DEPENDENCY_POLICIES.len() - 1]) + .is_err() + ); +} + +#[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_migration_targets(crate_name: &str) -> BTreeMap { + MIGRATION_POLICIES + .iter() + .find(|policy| policy.crate_name == crate_name) + .unwrap_or_else(|| panic!("migration policy must cover {crate_name}")) + .targets + .iter() + .map(|target| { + ( + target.module.to_owned(), + MigrationTarget { + target_step: target.target_step.to_owned(), + destination: target.destination.to_owned(), + }, + ) + }) + .collect() +} + +fn validate_migration_targets(crate_name: &str, config: &CeilingsFile) -> Result<(), String> { + let expected = expected_migration_targets(crate_name); + if config.migration_targets != expected { + return Err(format!( + "{crate_name} migration targets must match the exact phase policy: expected {expected:?}, got {:?}", + config.migration_targets + )); + } + for module in config.migration_targets.keys() { + if !config.modules.contains_key(module) { + return Err(format!( + "{crate_name} migration target names unknown module {module}" + )); + } + } + Ok(()) +} + +#[test] +fn module_ceilings_cover_sources_and_name_migration_targets() { + for crate_name in STT_CRATES { + let src = crate_root(crate_name).join("src"); + let config = ceilings(crate_name); + assert!( + config.public_root_budget > 0, + "{crate_name} public root budget must be a strict positive ceiling" + ); + 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]; + assert!( + lines <= ceiling, + "{crate_name}/{module} grew to {lines} lines past its exact ceiling {ceiling}" + ); + } + validate_migration_targets(crate_name, &config).unwrap_or_else(|error| panic!("{error}")); + } +} + +#[test] +fn missing_migration_target_is_rejected() { + let config = CeilingsFile { + public_root_budget: 0, + migration_targets: BTreeMap::new(), + modules: BTreeMap::from([("engine.rs".to_owned(), 1)]), + }; + assert!(validate_migration_targets("gateway-stt-engine", &config).is_err()); +} + +#[test] +fn misspelled_migration_section_is_rejected() { + let malformed = r#" + public_root_budget = 2 + + [migration_targtes] + + [modules] + "lib.rs" = 1 + "#; + + assert!(parse_ceilings(malformed).is_err()); +} + +#[test] +fn gateway_step_15_migrations_are_pinned_to_their_destinations() { + let expected = expected_migration_targets("gateway-stt"); + assert_eq!( + expected["api.rs"], + MigrationTarget { + target_step: "Step 15".to_owned(), + destination: "batch.rs".to_owned(), + } + ); + assert_eq!( + expected["runtime.rs"], + MigrationTarget { + target_step: "Step 15".to_owned(), + destination: "service.rs, artifacts.rs, generation.rs, status.rs, and model.rs" + .to_owned(), + } + ); +} + +#[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/main.rs b/crates/gateway-stt/tests/it/main.rs index 5b5bc94a..2524f263 100644 --- a/crates/gateway-stt/tests/it/main.rs +++ b/crates/gateway-stt/tests/it/main.rs @@ -3,6 +3,7 @@ #[path = "../common/mod.rs"] mod common; +mod architecture; mod batch; mod legacy_stream; mod realtime_fixtures; diff --git a/crates/gateway-whisper-ffi/module-ceilings.toml b/crates/gateway-whisper-ffi/module-ceilings.toml new file mode 100644 index 00000000..22083164 --- /dev/null +++ b/crates/gateway-whisper-ffi/module-ceilings.toml @@ -0,0 +1,16 @@ +# Exact source and public-root ratchets for the Whisper FFI leaf. +# Physical lines include comments and blanks. A source file may shrink but +# may not exceed its recorded ceiling. + +public_root_budget = 6 + +[migration_targets] + +[modules] +"context.rs" = 226 +"error.rs" = 114 +"lib.rs" = 79 +"library.rs" = 204 +"log.rs" = 116 +"params.rs" = 189 +"raw.rs" = 151 diff --git a/tools/check-stt-architecture.mjs b/tools/check-stt-architecture.mjs new file mode 100644 index 00000000..8810d628 --- /dev/null +++ b/tools/check-stt-architecture.mjs @@ -0,0 +1,308 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync } 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 STT_CRATES = [ + "gateway-stt", + "gateway-stt-engine", + "gateway-stt-backend-whisper", + "gateway-whisper-ffi", +]; + +function fail(message) { + throw new Error(message); +} + +export function requireToolVersion(tool, output, expected) { + const actual = output.trim(); + if (actual !== `${tool} ${expected}`) { + fail(`architecture gate requires ${tool} ${expected}, 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; +} + +function publicRootBudget(source, crateName) { + const matches = [ + ...source.matchAll(/^\s*public_root_budget\s*=\s*(\d+)\s*$/gm), + ]; + if (matches.length !== 1) { + fail( + `${crateName}/module-ceilings.toml must contain exactly one integer public_root_budget`, + ); + } + return Number(matches[0][1]); +} + +function runCargo(root, args) { + const result = spawnSync("cargo", args, { + cwd: root, + encoding: "utf8", + 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; +} + +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)), ".."); + requireToolVersion( + "cargo-modules", + runCargo(root, ["modules", "--version"]), + CARGO_MODULES_VERSION, + ); + requireToolVersion( + "cargo-public-api", + runCargo(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 = runCargo(root, [ + "public-api", + "-p", + crateName, + "-sss", + "--color", + "never", + ]); + const rootNames = countEffectiveRootNames( + publicApi, + crateName.replaceAll("-", "_"), + ); + const ceilingPath = join(root, "crates", crateName, "module-ceilings.toml"); + const budget = publicRootBudget(readFileSync(ceilingPath, "utf8"), crateName); + if (rootNames > budget) { + fail( + `${crateName} exposes ${rootNames} effective root names past its budget ${budget}`, + ); + } + console.log(`${crateName}: acyclic, public roots ${rootNames}/${budget}`); + } +} + +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..1146f777 --- /dev/null +++ b/tools/check-stt-architecture.test.mjs @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + assertAcyclic, + countEffectiveRootNames, + parseCargoModulesDot, + requireToolVersion, +} from "./check-stt-architecture.mjs"; + +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("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/, + ); +}); diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index a10d0cb7..88433f32 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -175,7 +175,7 @@ isProject: false - 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 internal module graphs for all four STT crates and line-count ceilings for every STT source module. Register each new module when created and never grow the legacy monolith before deleting it. + - 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. @@ -216,6 +216,7 @@ isProject: false - 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. - 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. @@ -328,7 +329,7 @@ isProject: false 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 20: `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 21 through 25, adds only `workshop-server -> shared-loopback`. Final Phase C, Step 26 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 the unfiltered architecture harness. +The architecture harness enforces exact workspace-package edges across normal, development, and build dependencies, ignoring self-dependencies. Phase A, Steps 7 through 20: `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 21 through 25, adds only `workshop-server -> shared-loopback`. Final Phase C, Step 26 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 - c6198001 @@ -389,11 +390,15 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### Step 7: Establish exact architecture ratchets - 0cbeb1b2 -- Artifacts: create `crates/gateway-stt/tests/it/architecture.rs`, register it in `tests/it/main.rs`, and create `module-ceilings.toml` in all four STT crates. -- Scope: enforce the stated temporary and final workspace-edge allowlists, unsafe leaf, no cycles, current ceilings, and public budgets; temporary exceptions name their removal step. +- 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. @@ -405,6 +410,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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. @@ -416,6 +422,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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. @@ -430,6 +437,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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. @@ -442,6 +450,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `C:\Users\Vinnie\cursor\promptforge`: `cargo tree -p gateway-stt -i base64` - `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. @@ -452,6 +461,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - 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. @@ -462,6 +472,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - 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. @@ -472,6 +483,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - 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 13; complete item ownership gates facade replacement and generation quiescence. @@ -483,6 +495,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 14; every current reverse consumer compiles and tests in this API-changing commit. @@ -493,6 +506,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - 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. @@ -503,6 +517,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - 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 16; cancellation-at-every-await and rollback outcomes gate route mounting. @@ -522,6 +537,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 15 facade and Step 17 lifecycle; status correctness gates route publication. @@ -531,6 +547,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - 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 19; the independent Gateway fixture path gates Workshop relay work. @@ -594,6 +611,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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. @@ -602,6 +620,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - 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` @@ -625,6 +644,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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` diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 2d9f491d..dac0306d 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -142,3 +142,4 @@ N13 | observation | global-state @ crates/gateway-stt-backend-whisper/src/prompt 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 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 +N17 | observation | Violates A2 @ crates/gateway-stt/tests/it/architecture.rs: not determinable from diff | Enforce exact STT architecture ratchets From 75f4cb309d2ed249e75908363a3efa7e93d90d41 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 02:30:15 -0700 Subject: [PATCH 15/86] Bound transcription workers and expose test fixtures Bounded queues now reject excess speech jobs, contain worker panics, and join decoding threads during shutdown. Feature-gated scripted decoders provide deterministic downstream and route tests without a production constructor. - `Transcriber` replaces unbounded submission with fixed-capacity admission and owns a shared stop flag that closes admission before its thread joins. - `test_fixtures` exposes scripted factory and decoder controls only when consumers enable the fixture feature. - `TranscribeError::Overloaded` reports a full queue without waiting, and `TranscribeError::WorkerPanicked` separates panics from a disconnected worker. - `scripted_workers_can_be_injected_without_a_production_constructor` sends a multipart request through the router and checks the scripted transcript and decoded samples. Design: new feature-flag @ crates/gateway-stt-backend-whisper/Cargo.toml::test-fixtures Design: new feature-flag @ crates/gateway-stt-engine/Cargo.toml::test-fixtures Design: new surface-growth @ crates/gateway-stt-engine/src/engine.rs::SttEngine::shutdown boundary: pub Design: new surface-growth @ crates/gateway-stt-engine/src/error.rs::TranscribeError boundary: pub Design: new surface-growth @ crates/gateway-stt-engine/src/lib.rs::test_fixtures boundary: pub Design: new shared-mutable-state @ crates/gateway-stt-engine/src/test_fixtures.rs::ScriptedDecoder Design: new temporal-coupling @ crates/gateway-stt-engine/src/test_fixtures.rs::ScriptedDecoder Design: new shared-mutable-state @ crates/gateway-stt-engine/src/worker.rs::Transcriber Design: extends flag-parameter @ crates/gateway-stt-engine/src/worker.rs::worker_loop deps: &AtomicBool,&dyn ModelFactory,&mpsc::Receiver,&mpsc::SyncSender>,bool Design: new feature-flag @ crates/gateway-stt/Cargo.toml::test-fixtures Design: new surface-growth @ crates/gateway-stt/src/lib.rs::test_fixtures boundary: pub Design: new facade @ crates/gateway-stt/src/lib.rs::test_fixtures Design: new constructor-injection @ crates/gateway-stt/src/runtime.rs::SttRuntime::from_scripted_engine Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- crates/gateway-stt-backend-whisper/Cargo.toml | 3 + crates/gateway-stt-engine/Cargo.toml | 3 + .../gateway-stt-engine/module-ceilings.toml | 17 +- crates/gateway-stt-engine/src/engine.rs | 34 +- crates/gateway-stt-engine/src/error.rs | 10 + crates/gateway-stt-engine/src/lib.rs | 2 + .../gateway-stt-engine/src/test_fixtures.rs | 488 ++++++++++++++++++ crates/gateway-stt-engine/src/worker.rs | 383 ++++++++++++-- crates/gateway-stt/Cargo.toml | 6 + crates/gateway-stt/module-ceilings.toml | 6 +- crates/gateway-stt/src/lib.rs | 4 +- crates/gateway-stt/src/runtime.rs | 15 + crates/gateway-stt/src/test_fixtures.rs | 32 ++ crates/gateway-stt/tests/it/architecture.rs | 21 +- crates/gateway/Cargo.toml | 1 + crates/gateway/src/test_support.rs | 88 ++++ vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- vibe/archdoc-next.md | 11 +- 18 files changed, 1052 insertions(+), 74 deletions(-) create mode 100644 crates/gateway-stt-engine/src/test_fixtures.rs diff --git a/crates/gateway-stt-backend-whisper/Cargo.toml b/crates/gateway-stt-backend-whisper/Cargo.toml index 4f246834..6f4d9a69 100644 --- a/crates/gateway-stt-backend-whisper/Cargo.toml +++ b/crates/gateway-stt-backend-whisper/Cargo.toml @@ -20,5 +20,8 @@ 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-engine/Cargo.toml b/crates/gateway-stt-engine/Cargo.toml index b1ca0360..bd570185 100644 --- a/crates/gateway-stt-engine/Cargo.toml +++ b/crates/gateway-stt-engine/Cargo.toml @@ -13,5 +13,8 @@ description = "Backend-neutral PromptForge speech decoding workers and audio pol thiserror.workspace = true tokio.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 index 18256f81..29cfc9b1 100644 --- a/crates/gateway-stt-engine/module-ceilings.toml +++ b/crates/gateway-stt-engine/module-ceilings.toml @@ -4,18 +4,13 @@ public_root_budget = 7 -[migration_targets."engine.rs"] -target_step = "Step 8" -destination = "bounded worker dispatch" - -[migration_targets."worker.rs"] -target_step = "Step 8" -destination = "bounded worker command queues" +[migration_targets] [modules] "decoder.rs" = 42 -"engine.rs" = 381 -"error.rs" = 67 -"lib.rs" = 16 +"engine.rs" = 403 +"error.rs" = 77 +"lib.rs" = 18 "policy.rs" = 52 -"worker.rs" = 115 +"test_fixtures.rs" = 488 +"worker.rs" = 428 diff --git a/crates/gateway-stt-engine/src/engine.rs b/crates/gateway-stt-engine/src/engine.rs index 71f6acca..5dbde7d5 100644 --- a/crates/gateway-stt-engine/src/engine.rs +++ b/crates/gateway-stt-engine/src/engine.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use std::time::Duration; -use crate::worker::Transcriber; +use crate::worker::{FINAL_JOB_CAPACITY, INTERIM_JOB_CAPACITY, Transcriber}; use crate::{ModelFactory, SAMPLE_RATE, TranscribeError}; /// The STT engine: one required interim worker and one optional final worker. @@ -49,11 +49,12 @@ impl SttEngine { let gpu_available = factory.gpu_available(); let factory: Arc = Arc::new(factory); - let (transcriber, interim_init) = - Transcriber::spawn("stt-interim", Arc::clone(&factory), false)?; - let (final_worker, final_init) = - Transcriber::spawn("stt-final", Arc::clone(&factory), true)?; - + let (transcriber, interim_init) = Transcriber::spawn( + "stt-interim", + Arc::clone(&factory), + false, + INTERIM_JOB_CAPACITY, + )?; let interim_exists = interim_init .recv() .map_err(|_| TranscribeError::WorkerGone)??; @@ -62,6 +63,8 @@ impl SttEngine { "the interim decoder is required".to_owned(), )); } + let (final_worker, final_init) = + Transcriber::spawn("stt-final", Arc::clone(&factory), true, FINAL_JOB_CAPACITY)?; let final_pass = if final_init .recv() .map_err(|_| TranscribeError::WorkerGone)?? @@ -133,6 +136,25 @@ impl SttEngine { None => None, } } + + /// 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. + pub fn shutdown(&mut self) { + self.transcriber.shutdown(); + if let Some(final_pass) = &mut self.final_pass { + final_pass.shutdown(); + } + self.final_pass = None; + } +} + +impl Drop for SttEngine { + fn drop(&mut self) { + self.shutdown(); + } } #[cfg(test)] diff --git a/crates/gateway-stt-engine/src/error.rs b/crates/gateway-stt-engine/src/error.rs index 2d8b9ec5..ca9f00f9 100644 --- a/crates/gateway-stt-engine/src/error.rs +++ b/crates/gateway-stt-engine/src/error.rs @@ -37,6 +37,16 @@ pub enum TranscribeError { #[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, + /// The STT engine configuration is invalid. #[non_exhaustive] #[error("invalid STT configuration: {0}")] diff --git a/crates/gateway-stt-engine/src/lib.rs b/crates/gateway-stt-engine/src/lib.rs index 8f5e70b0..61ac7c2c 100644 --- a/crates/gateway-stt-engine/src/lib.rs +++ b/crates/gateway-stt-engine/src/lib.rs @@ -8,6 +8,8 @@ mod decoder; mod engine; mod error; mod policy; +#[cfg(feature = "test-fixtures")] +pub mod test_fixtures; mod worker; pub use decoder::{Decoder, ModelFactory}; 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..10e3162d --- /dev/null +++ b/crates/gateway-stt-engine/src/test_fixtures.rs @@ -0,0 +1,488 @@ +//! Deterministic decoder fixtures for downstream integration tests. + +use std::collections::VecDeque; +use std::sync::{Arc, Condvar, Mutex, PoisonError}; +use std::thread::ThreadId; +use std::time::Duration; + +use crate::{Decoder, ModelFactory, 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)] +struct DecoderState { + outcomes: VecDeque, + requests: Vec<(Vec, Vec, String)>, + creation_thread: Option, + decode_threads: Vec, + waiters: usize, + park: ParkState, + worker_dropped: bool, +} + +/// A cloneable controller for one deterministic decoder. +#[derive(Clone, Debug, Default)] +pub struct ScriptedDecoder { + shared: Arc<(Mutex, Condvar)>, +} + +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); + } + + /// Parks the next decode until [`Self::release`] is called. + pub fn park_next(&self) { + self.state().park = ParkState::Armed; + } + + /// Releases a decode parked by [`Self::park_next`]. + pub fn release(&self) { + let (_, changed) = &*self.shared; + self.state().park = ParkState::Released; + changed.notify_all(); + } + + /// 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 a parked decode has entered its rendezvous. + #[must_use] + pub fn wait_until_parked(&self, timeout: Duration) -> bool { + self.wait_for(timeout, |state| state.park == ParkState::Parked) + } + + /// Returns all captured `(samples, guidance, finalized)` requests. + #[must_use] + pub fn requests(&self) -> Vec<(Vec, Vec, String)> { + 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 + } + + 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) + } + + fn mark_created(&self) { + self.state().creation_thread = Some(std::thread::current().id()); + } +} + +struct WorkerDecoder(ScriptedDecoder); + +impl Decoder for WorkerDecoder { + fn transcribe( + &mut self, + samples: &[f32], + guidance: &[String], + finalized: &str, + ) -> Result { + let (state, changed) = &*self.0.shared; + let mut state = state.lock().unwrap_or_else(PoisonError::into_inner); + state + .requests + .push((samples.to_vec(), guidance.to_vec(), finalized.to_owned())); + 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; + } + 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()), + } + } +} + +impl Drop for WorkerDecoder { + fn drop(&mut self) { + let (_, changed) = &*self.0.shared; + self.0.state().worker_dropped = true; + changed.notify_all(); + } +} + +/// 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 + } +} + +impl ModelFactory for ScriptedModelFactory { + fn create_interim(&self) -> Result, TranscribeError> { + assert!(!self.panic_interim, "scripted interim factory panic"); + if let Some(message) = &self.interim_failure { + return Err(TranscribeError::InvalidConfig(message.clone())); + } + self.interim.mark_created(); + Ok(Box::new(WorkerDecoder(self.interim.clone()))) + } + + fn create_final(&self) -> Result>, TranscribeError> { + assert!(!self.panic_final, "scripted final factory panic"); + if let Some(message) = &self.final_failure { + return Err(TranscribeError::InvalidConfig(message.clone())); + } + let Some(decoder) = &self.final_decoder else { + return Ok(None); + }; + decoder.mark_created(); + Ok(Some(Box::new(WorkerDecoder(decoder.clone())))) + } + + fn gpu_available(&self) -> bool { + self.gpu_available + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::SttEngine; + + 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) { + let (state, changed) = &*decoder.shared; + let state = state.lock().unwrap_or_else(PoisonError::into_inner); + let (state, timeout) = changed + .wait_timeout_while(state, Duration::from_secs(1), |state| state.waiters == 0) + .unwrap_or_else(PoisonError::into_inner); + assert!( + !timeout.timed_out() && state.waiters == 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 mut engine = SttEngine::new( + ScriptedModelFactory::new(interim.clone()) + .with_final(final_decoder.clone()) + .with_gpu_available(true), + 15, + 500, + ) + .expect("scripted workers start"); + + assert_eq!( + engine + .transcribe(vec![0.25], vec!["term".to_owned()]) + .await + .expect("interim succeeds"), + "interim" + ); + assert_eq!( + engine + .transcribe_final(vec![0.5], vec!["name".to_owned()], "history".to_owned(),) + .await + .expect("final worker exists") + .expect("final succeeds"), + "final" + ); + assert!(engine.gpu_transcription_available()); + assert_eq!( + interim.requests(), + vec![(vec![0.25], vec!["term".to_owned()], String::new())] + ); + assert_eq!( + final_decoder.requests(), + vec![(vec![0.5], vec!["name".to_owned()], "history".to_owned())] + ); + 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(); + 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(), + 15, + 500, + ) + .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(), + 15, + 500, + ) + .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), 15, 500) + .expect("scripted worker starts"); + let first = engine + .transcribe(Vec::new(), Vec::new()) + .await + .expect_err("panic is reported"); + assert!(matches!(first, TranscribeError::WorkerPanicked)); + let second = engine + .transcribe(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 mut engine = SttEngine::new(ScriptedModelFactory::new(interim.clone()), 15, 500) + .expect("scripted worker starts"); + + engine + .transcribe(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(); + 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 mut engine = SttEngine::new(ScriptedModelFactory::new(interim.clone()), 15, 500) + .expect("scripted worker starts"); + let error = engine + .transcribe(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(); + 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), + 15, + 500, + ) + .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), + 15, + 500, + ) + .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()); + } +} diff --git a/crates/gateway-stt-engine/src/worker.rs b/crates/gateway-stt-engine/src/worker.rs index 077c8899..d55d0610 100644 --- a/crates/gateway-stt-engine/src/worker.rs +++ b/crates/gateway-stt-engine/src/worker.rs @@ -1,9 +1,14 @@ //! One backend-neutral transcription worker. -use std::sync::Arc; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, mpsc}; use crate::{Decoder, ModelFactory, TranscribeError}; +pub(crate) const INTERIM_JOB_CAPACITY: usize = 8; +pub(crate) const FINAL_JOB_CAPACITY: usize = 8; + struct Job { samples: Vec, guidance: Vec, @@ -14,7 +19,8 @@ struct Job { /// Handle to a decoder confined to its worker thread. #[derive(Debug)] pub(crate) struct Transcriber { - job_tx: Option>, + job_tx: Option>, + stopping: Arc, worker: Option>, } @@ -24,52 +30,71 @@ impl Transcriber { name: &'static str, factory: Arc, final_model: bool, - ) -> 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); + 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(), final_model, &job_rx, &init_tx)) + .spawn(move || { + worker_loop( + factory.as_ref(), + final_model, + &job_rx, + &init_tx, + &worker_stopping, + ); + }) .map_err(TranscribeError::SpawnWorker)?; Ok(( Self { job_tx: Some(job_tx), + stopping, worker: Some(worker), }, init_rx, )) } - pub(super) async fn transcribe( + fn submit( &self, samples: Vec, guidance: Vec, finalized: String, - ) -> Result { + ) -> Result>, TranscribeError> + { let (reply, reply_rx) = tokio::sync::oneshot::channel(); let Some(job_tx) = &self.job_tx else { return Err(TranscribeError::WorkerGone); }; job_tx - .send(Job { + .try_send(Job { samples, guidance, finalized, reply, }) - .map_err(|_| TranscribeError::WorkerGone)?; + .map_err(|error| match error { + mpsc::TrySendError::Full(_) => TranscribeError::Overloaded, + mpsc::TrySendError::Disconnected(_) => TranscribeError::WorkerGone, + })?; + Ok(reply_rx) + } + + pub(super) async fn transcribe( + &self, + samples: Vec, + guidance: Vec, + finalized: String, + ) -> Result { + let reply_rx = self.submit(samples, guidance, finalized)?; reply_rx.await.map_err(|_| TranscribeError::WorkerGone)? } -} -impl Drop for Transcriber { - fn drop(&mut self) { + pub(super) fn shutdown(&mut self) { + self.stopping.store(true, Ordering::Release); drop(self.job_tx.take()); if let Some(worker) = self.worker.take() { let _ignored = worker.join(); @@ -77,16 +102,29 @@ impl Drop for Transcriber { } } +impl Drop for Transcriber { + fn drop(&mut self) { + self.shutdown(); + } +} + fn worker_loop( factory: &dyn ModelFactory, final_model: bool, - job_rx: &std::sync::mpsc::Receiver, - init_tx: &std::sync::mpsc::SyncSender>, + job_rx: &mpsc::Receiver, + init_tx: &mpsc::SyncSender>, + stopping: &AtomicBool, ) { - let decoder = if final_model { - factory.create_final() - } else { - factory.create_interim().map(Some) + let decoder = catch_unwind(AssertUnwindSafe(|| { + if final_model { + factory.create_final() + } else { + factory.create_interim().map(Some) + } + })); + let decoder = match decoder { + Ok(result) => result, + Err(_) => Err(TranscribeError::WorkerPanicked), }; let Some(mut decoder): Option> = (match decoder { Ok(decoder) => { @@ -96,20 +134,295 @@ fn worker_loop( decoder } Err(error) => { - // Initialization failure is terminal, and cancellation leaves no - // engine constructor to receive it. - match init_tx.send(Err(error)) { - Ok(()) | Err(_) => return, - } + // Initialization is terminal; cancellation leaves no constructor to receive it. + drop(init_tx.send(Err(error))); + return; } }) else { return; }; - while let Ok(job) = job_rx.recv() { - let result = decoder.transcribe(&job.samples, &job.guidance, &job.finalized); - if job.reply.send(result).is_err() { - // A canceled caller abandons only its reply; the stateless worker - // remains available for later jobs. + 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.transcribe(&job.samples, &job.guidance, &job.finalized) + })); + 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_interim(&self) -> Result, TranscribeError> { + Ok(Box::new(ParkDecoder(self.0.clone()))) + } + + fn create_final(&self) -> Result>, TranscribeError> { + Ok(Some(Box::new(ParkDecoder(self.0.clone())))) + } + + fn gpu_available(&self) -> bool { + false + } + } + + struct ParkDecoder(ParkControl); + + impl Decoder for ParkDecoder { + fn transcribe( + &mut self, + _samples: &[f32], + _guidance: &[String], + _finalized: &str, + ) -> 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 parked_worker(final_model: bool, 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, final_model, capacity) + .expect("worker spawns"); + assert!( + startup + .recv() + .expect("startup outcome arrives") + .expect("decoder starts") + ); + (worker, control) + } + + fn assert_queue_boundary(final_model: bool, capacity: usize) { + let (mut worker, control) = parked_worker(final_model, capacity); + let running = worker + .submit(Vec::new(), Vec::new(), String::new()) + .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(Vec::new(), Vec::new(), String::new()) + .expect("every queue slot is admitted") + }) + .collect::>(); + let error = worker + .submit(Vec::new(), Vec::new(), String::new()) + .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(); + 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(false, INTERIM_JOB_CAPACITY); + } + + #[test] + fn final_queue_accepts_exact_capacity_and_rejects_capacity_plus_one() { + assert_eq!(FINAL_JOB_CAPACITY, 8); + assert_queue_boundary(true, FINAL_JOB_CAPACITY); + } + + #[test] + fn cancellation_while_running_discards_only_that_reply() { + let (mut worker, control) = parked_worker(false, INTERIM_JOB_CAPACITY); + let cancelled = worker + .submit(Vec::new(), Vec::new(), String::new()) + .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(Vec::new(), Vec::new(), String::new()) + .expect("worker remains available"); + assert_eq!( + next.blocking_recv() + .expect("worker replies") + .expect("decode succeeds"), + "scripted" + ); + worker.shutdown(); + assert_eq!(control.calls(), 2); + } + + #[test] + fn shutdown_joins_the_worker_and_is_idempotent() { + let (mut worker, control) = parked_worker(false, INTERIM_JOB_CAPACITY); + worker.shutdown(); + worker.shutdown(); + control.wait_for( + |state| state.dropped, + "shutdown drops the decoder before returning", + ); + assert!( + worker + .submit(Vec::new(), Vec::new(), String::new()) + .is_err() + ); + } + + #[test] + fn shutdown_waits_for_running_decode_instead_of_detaching() { + let (worker, control) = parked_worker(false, INTERIM_JOB_CAPACITY); + let reply = worker + .submit(Vec::new(), Vec::new(), String::new()) + .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 || { + let mut worker = worker; + worker.shutdown(); + 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/Cargo.toml b/crates/gateway-stt/Cargo.toml index 74bfd712..0456fbc2 100644 --- a/crates/gateway-stt/Cargo.toml +++ b/crates/gateway-stt/Cargo.toml @@ -32,5 +32,11 @@ 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 index afa4b199..7c10b1c3 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -22,9 +22,9 @@ destination = "independent committed-item finalization" [modules] "api.rs" = 625 -"lib.rs" = 21 -"runtime.rs" = 439 +"lib.rs" = 23 +"runtime.rs" = 454 "segment.rs" = 233 "stt.rs" = 712 "take.rs" = 663 -"test_fixtures.rs" = 37 +"test_fixtures.rs" = 69 diff --git a/crates/gateway-stt/src/lib.rs b/crates/gateway-stt/src/lib.rs index 0ad651b3..6439ce33 100644 --- a/crates/gateway-stt/src/lib.rs +++ b/crates/gateway-stt/src/lib.rs @@ -12,8 +12,10 @@ mod runtime; mod segment; mod stt; mod take; -#[cfg(test)] +#[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}; diff --git a/crates/gateway-stt/src/runtime.rs b/crates/gateway-stt/src/runtime.rs index febb644c..980561fb 100644 --- a/crates/gateway-stt/src/runtime.rs +++ b/crates/gateway-stt/src/runtime.rs @@ -161,6 +161,21 @@ impl SttRuntime { } } + #[cfg(feature = "test-fixtures")] + pub(crate) fn from_scripted_engine( + engine: SttEngine, + interim: String, + final_model: Option, + guidance: Vec, + ) -> SttRuntime { + let state = SttState::default(); + state.activate(engine, interim, final_model, guidance); + SttRuntime { + state, + active: true, + } + } + /// Provisions the selected STT pair and loads its engine. /// /// A profile with no STT entries returns an inactive runtime. An diff --git a/crates/gateway-stt/src/test_fixtures.rs b/crates/gateway-stt/src/test_fixtures.rs index 9bc4fea4..b06ac2ff 100644 --- a/crates/gateway-stt/src/test_fixtures.rs +++ b/crates/gateway-stt/src/test_fixtures.rs @@ -1,11 +1,42 @@ //! Native fixtures used only by this crate's unit tests. +#[cfg(test)] use std::path::{Path, PathBuf}; +#[cfg(feature = "test-fixtures")] +pub use gateway_stt_engine::test_fixtures::{ScriptedDecoder, ScriptedModelFactory}; + +#[cfg(feature = "test-fixtures")] +use crate::SttRuntime; +#[cfg(feature = "test-fixtures")] +use gateway_stt_engine::{SttEngine, TranscribeError}; + +/// Builds a speech runtime around deterministic scripted workers. +/// +/// # Errors +/// Returns engine policy, startup, or worker construction failures. +#[cfg(feature = "test-fixtures")] +pub fn scripted_runtime( + factory: ScriptedModelFactory, + window_seconds: u64, + interval_ms: u64, +) -> Result { + let engine = SttEngine::new(factory, window_seconds, interval_ms)?; + let final_name = engine.has_final_pass().then(|| "scripted-final".to_owned()); + Ok(SttRuntime::from_scripted_engine( + engine, + "scripted-interim".to_owned(), + final_name, + Vec::new(), + )) +} + +#[cfg(test)] pub(crate) fn require_model() -> PathBuf { require_fixture("PROMPTFORGE_WHISPER_MODEL", "ggml-tiny.en.bin") } +#[cfg(test)] pub(crate) fn jfk_samples() -> Vec { let path = require_fixture("PROMPTFORGE_WHISPER_AUDIO", "jfk.wav"); let mut reader = hound::WavReader::open(path).expect("JFK fixture opens"); @@ -19,6 +50,7 @@ pub(crate) fn jfk_samples() -> Vec { .collect() } +#[cfg(test)] fn require_fixture(variable: &str, fallback: &str) -> PathBuf { let path = std::env::var_os(variable).map_or_else( || { diff --git a/crates/gateway-stt/tests/it/architecture.rs b/crates/gateway-stt/tests/it/architecture.rs index 88d2963f..0dcf8d6e 100644 --- a/crates/gateway-stt/tests/it/architecture.rs +++ b/crates/gateway-stt/tests/it/architecture.rs @@ -146,18 +146,7 @@ const MIGRATION_POLICIES: [MigrationPolicy; 4] = [ }, MigrationPolicy { crate_name: "gateway-stt-engine", - targets: &[ - MigrationPolicyTarget { - module: "engine.rs", - target_step: "Step 8", - destination: "bounded worker dispatch", - }, - MigrationPolicyTarget { - module: "worker.rs", - target_step: "Step 8", - destination: "bounded worker command queues", - }, - ], + targets: &[], }, MigrationPolicy { crate_name: "gateway-stt-backend-whisper", @@ -494,13 +483,13 @@ fn module_ceilings_cover_sources_and_name_migration_targets() { } #[test] -fn missing_migration_target_is_rejected() { +fn completed_engine_migration_targets_are_removed() { let config = CeilingsFile { - public_root_budget: 0, + public_root_budget: 7, migration_targets: BTreeMap::new(), - modules: BTreeMap::from([("engine.rs".to_owned(), 1)]), + modules: BTreeMap::from([("engine.rs".to_owned(), 1), ("worker.rs".to_owned(), 1)]), }; - assert!(validate_migration_targets("gateway-stt-engine", &config).is_err()); + assert!(validate_migration_targets("gateway-stt-engine", &config).is_ok()); } #[test] diff --git a/crates/gateway/Cargo.toml b/crates/gateway/Cargo.toml index 5343b274..f26bda02 100644 --- a/crates/gateway/Cargo.toml +++ b/crates/gateway/Cargo.toml @@ -139,6 +139,7 @@ png.workspace = true # test-util pauses time so the progress heartbeat test runs instantly. tokio = { workspace = true, features = ["test-util"] } gateway-routing = { workspace = true, features = ["test-helpers"] } +gateway-stt = { 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/src/test_support.rs b/crates/gateway/src/test_support.rs index 7e195f7d..0771f4e4 100644 --- a/crates/gateway/src/test_support.rs +++ b/crates/gateway/src/test_support.rs @@ -62,6 +62,21 @@ 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) async fn app_state_with_scripted_stt( + config: Config, + factory: gateway_stt::test_fixtures::ScriptedModelFactory, +) -> Result { + let runtime = gateway_stt::test_fixtures::scripted_runtime(factory, 15, 500) + .map_err(|error| error.to_string())?; + let stt_state = runtime.state(); + let mut state = app_state(config, None); + state.live.write().await.stt = Some(runtime); + state.stt_state = stt_state; + 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. @@ -117,3 +132,76 @@ 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())) + .await + .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); + assert_eq!( + decoder.requests(), + vec![(vec![0.25], Vec::new(), String::new())] + ); + } +} diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 88433f32..cfcf719c 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -402,7 +402,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### Step 8: Bound workers and expose scripted tests - fb3a5be9 - 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index dac0306d..30fadcbf 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -137,9 +137,18 @@ N8 | observation | oversized-unit @ crates/gateway-stt/tests/it/legacy_stream.rs 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 N10 | observation | Violates A96 @ crates/gateway-stt/src/api.rs: not determinable from diff | Move take ownership into gateway STT 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 +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 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 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 N17 | observation | Violates A2 @ crates/gateway-stt/tests/it/architecture.rs: not determinable from diff | Enforce exact STT architecture ratchets +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 +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 +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 +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 +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 +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 +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 From 12a233fef40b13663f846b77c7794e259f210d0c Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 03:14:11 -0700 Subject: [PATCH 16/86] Harden STT workers and extend release gates Run backend-neutral ownership and bounded queue tests under a pinned interpreter. Make decode jobs and engine policy explicit values, bound worker startup and cleanup, then place the approved serving-log correction before final release verification. - `DecodeRequest` replaces separate mode, sample, guidance, and finalized-history arguments with one owned job. `EnginePolicy` owns validated capture settings, the hardware capability fact, and the shared startup timeout. - `vibe/2026-09-05-2-generic-realtime-stt.md` records completed markers through the interpreter work, replaces commit hashes in completed headings, and inserts serving-log bookends before full release verification. - `SttEngine::new` starts interim and final construction under one absolute deadline. It aggregates role failures, preserves partial-cleanup failures, and explicitly abandons only non-preemptible timed-out startup handles. - `SttEngine::shutdown` joins every ordinary worker and returns one or multiple panic outcomes. Repeated calls preserve the observed failures. - `.github/workflows/stt-miri.yml` pins pure ownership and queue checks to the selected toolchain and adds native checks with hash-verified runtime, model, and audio fixtures. - `ScriptedDecoder` adds construction rendezvous and drop-panic controls to shared fixture state. Tests cover exact queue boundaries, cancellation, startup deadlines, failure aggregation, cleanup, thread confinement, and shutdown. - `.github/workflows/stt-miri.yml` keeps sockets, dynamic FFI, native callbacks, and model loading outside the interpreter and assigns them to native CI. Design: new parameter-object @ crates/gateway-stt-engine/src/decoder.rs::DecodeRequest boundary: pub Design: new encapsulated-invariant @ crates/gateway-stt-engine/src/policy.rs::EnginePolicy boundary: pub Design: removes shared-parameter-cluster @ crates/gateway-stt-engine/src/engine.rs::SttEngine::transcribe_final Design: flag-parameter -> dispatch-on-tag @ crates/gateway-stt-engine/src/worker.rs::worker_loop deps: &AtomicBool,&dyn ModelFactory,&mpsc::Receiver,&mpsc::SyncSender>,DecodeMode Design: new dispatch-on-tag @ crates/gateway-stt-engine/src/engine.rs::SttEngine::decode boundary: pub Design: extends shared-mutable-state @ crates/gateway-stt-engine/src/test_fixtures.rs::ScriptedDecoder Design: extends temporal-coupling @ crates/gateway-stt-engine/src/test_fixtures.rs::ScriptedDecoder Violates: A2 - credential ownership in crates/gateway-stt/src/runtime.rs is not determinable from diff Violates: A96 - browser content bounds in crates/gateway-stt/src/api.rs are not determinable from diff Violates: A115 - control readiness during crates/gateway-stt/src/runtime.rs model startup is not determinable from diff Pending: N21 - compounds Pending: N22 - compounds Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- .github/workflows/stt-miri.yml | 109 ++++ .../gateway-stt-backend-whisper/src/model.rs | 82 ++-- .../tests/native_whisper.rs | 105 ++-- crates/gateway-stt-engine/AGENTS.md | 1 + .../gateway-stt-engine/module-ceilings.toml | 14 +- crates/gateway-stt-engine/src/decoder.rs | 85 +++- crates/gateway-stt-engine/src/engine.rs | 464 ++++++------------ crates/gateway-stt-engine/src/error.rs | 66 +-- crates/gateway-stt-engine/src/lib.rs | 8 +- crates/gateway-stt-engine/src/policy.rs | 112 ++++- crates/gateway-stt-engine/src/startup.rs | 48 ++ .../gateway-stt-engine/src/test_fixtures.rs | 278 ++++++++--- crates/gateway-stt-engine/src/translation.rs | 28 ++ crates/gateway-stt-engine/src/worker.rs | 187 +++---- .../tests/engine_contract.rs | 238 +++++++++ .../tests/startup_cleanup.rs | 271 ++++++++++ crates/gateway-stt/module-ceilings.toml | 10 +- crates/gateway-stt/src/api.rs | 32 +- crates/gateway-stt/src/runtime.rs | 11 +- crates/gateway-stt/src/segment.rs | 26 +- crates/gateway-stt/src/stt.rs | 45 +- crates/gateway-stt/src/take.rs | 16 +- crates/gateway-stt/src/test_fixtures.rs | 8 +- .../tests/common/native_runtime.rs | 33 ++ crates/gateway-stt/tests/it/legacy_stream.rs | 19 +- .../gateway-whisper-ffi/module-ceilings.toml | 2 +- crates/gateway-whisper-ffi/src/lib.rs | 1 + crates/gateway/src/test_support.rs | 9 +- vibe/2026-09-05-2-generic-realtime-stt.md | 41 +- vibe/archdoc-next.md | 13 +- 30 files changed, 1675 insertions(+), 687 deletions(-) create mode 100644 .github/workflows/stt-miri.yml create mode 100644 crates/gateway-stt-engine/src/startup.rs create mode 100644 crates/gateway-stt-engine/src/translation.rs create mode 100644 crates/gateway-stt-engine/tests/engine_contract.rs create mode 100644 crates/gateway-stt-engine/tests/startup_cleanup.rs create mode 100644 crates/gateway-stt/tests/common/native_runtime.rs diff --git a/.github/workflows/stt-miri.yml b/.github/workflows/stt-miri.yml new file mode 100644 index 00000000..d083d5f0 --- /dev/null +++ b/.github/workflows/stt-miri.yml @@ -0,0 +1,109 @@ +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-worker-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_ + + native-whisper: + runs-on: [self-hosted, windows, cuda] + timeout-minutes: 90 + env: + RUSTUP_TOOLCHAIN: stable + steps: + - uses: actions/checkout@v4 + + - uses: dtolnay/rust-toolchain@stable + + - name: Cache Cargo + uses: Swatinem/rust-cache@v2 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install Workshop UI dependencies + working-directory: crates/workshop-server/ui + run: npm ci + + - 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/crates/gateway-stt-backend-whisper/src/model.rs b/crates/gateway-stt-backend-whisper/src/model.rs index 546a89f1..14383164 100644 --- a/crates/gateway-stt-backend-whisper/src/model.rs +++ b/crates/gateway-stt-backend-whisper/src/model.rs @@ -3,7 +3,9 @@ use std::io::Read; use std::path::Path; -use gateway_stt_engine::{Decoder, MIN_WINDOW_SAMPLES, ModelFactory, TranscribeError, is_silence}; +use gateway_stt_engine::{ + DecodeMode, DecodeRequest, Decoder, EnginePolicy, ModelFactory, TranscribeError, +}; use gateway_whisper_ffi::{ FullParams, SamplingStrategy, WhisperContext, WhisperLibrary, WhisperState, }; @@ -49,47 +51,39 @@ impl WhisperModelFactory { gpu_available, }) } -} -impl ModelFactory for WhisperModelFactory { - fn create_interim(&self) -> Result, TranscribeError> { - let progress = self - .config - .progress - .as_ref() - .map(|handle| handle.child("interim", 1.0)); - WhisperDecoder::load( - &self.library, - &self.config.interim_model, - progress.as_ref(), - false, - ) - .map(|decoder| Box::new(decoder) as Box) + /// Whether the loaded runtime reports hardware acceleration. + #[must_use] + pub fn gpu_available(&self) -> bool { + self.gpu_available } +} - fn create_final(&self) -> Result>, TranscribeError> { - let Some(path) = &self.config.final_model else { - return Ok(None); +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("final", 1.0)); - WhisperDecoder::load(&self.library, path, progress.as_ref(), true) + .map(|handle| handle.child(progress_name, 1.0)); + WhisperDecoder::load(&self.library, path, progress.as_ref()) .map(|decoder| Some(Box::new(decoder) as Box)) } - - fn gpu_available(&self) -> bool { - self.gpu_available - } } #[derive(Debug)] struct WhisperDecoder { context: WhisperContext, state: WhisperState, - final_pass: bool, } impl WhisperDecoder { @@ -97,7 +91,6 @@ impl WhisperDecoder { library: &WhisperLibrary, path: &Path, progress: Option<&ProgressHandle>, - final_pass: bool, ) -> Result { let prewarm_leaf = progress.map(|handle| handle.child("prewarm", 1.0)); prewarm(path, prewarm_leaf.as_ref())?; @@ -110,40 +103,39 @@ impl WhisperDecoder { if let Some(leaf) = &init_leaf { leaf.complete(); } - Ok(Self { - context, - state, - final_pass, - }) + Ok(Self { context, state }) } } impl Decoder for WhisperDecoder { - fn transcribe( - &mut self, - samples: &[f32], - guidance: &[String], - finalized: &str, - ) -> Result { - if self.final_pass && (samples.len() < MIN_WINDOW_SAMPLES || is_silence(samples)) { + 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 self.final_pass { + let glossary_budget = if final_pass { GLOSSARY_TOKEN_BUDGET } else { MAX_PROMPT_TOKENS }; - let glossary = fit_glossary(&self.context, guidance, glossary_budget); - let prompt = if self.final_pass { - Some(final_prompt(&self.context, glossary.as_deref(), finalized)) + 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, - samples, + request.samples(), prompt.as_deref(), - !self.final_pass, + !final_pass, ) } } diff --git a/crates/gateway-stt-backend-whisper/tests/native_whisper.rs b/crates/gateway-stt-backend-whisper/tests/native_whisper.rs index 6deb2047..85d1b00b 100644 --- a/crates/gateway-stt-backend-whisper/tests/native_whisper.rs +++ b/crates/gateway-stt-backend-whisper/tests/native_whisper.rs @@ -1,4 +1,5 @@ //! Native characterization of the packaged Whisper backend contract. +//! Miri excludes native model loading and decode; packaged-runtime CI owns them. #![expect( clippy::expect_used, @@ -9,7 +10,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use gateway_stt_backend_whisper::{WhisperConfig, WhisperModelFactory}; -use gateway_stt_engine::SttEngine; +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."; @@ -60,7 +61,18 @@ fn engine_with_progress( ) -> SttEngine { let config = WhisperConfig::new(library, interim, final_model, progress); let factory = WhisperModelFactory::new(config).expect("packaged runtime loads"); - SttEngine::new(factory, 12, 500).expect("backend models load") + 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] @@ -81,31 +93,44 @@ async fn packaged_runtime_preserves_native_transcription_contract() { let unprompted = engine(library.clone(), model.clone(), Some(model.clone())); let interim = unprompted - .transcribe(samples.clone(), Vec::new()) + .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 - .transcribe_final(prompt_sensitive_clip.clone(), Vec::new(), String::new()) + .decode(request( + DecodeMode::Final, + prompt_sensitive_clip.clone(), + Vec::new(), + "", + )) .await - .expect("a final model is configured") .expect("unprompted final decode succeeds"); assert_eq!(unprompted_clip, UNPROMPTED_CLIP_TRANSCRIPT); let conditioning_transcript = unprompted - .transcribe_final(conditioning_clip, Vec::new(), String::new()) + .decode(request( + DecodeMode::Final, + conditioning_clip, + Vec::new(), + "", + )) .await - .expect("a final model is configured") .expect("conditioning decode succeeds"); let conditioned_clip = unprompted - .transcribe_final( + .decode(request( + DecodeMode::Final, prompt_sensitive_clip.clone(), Vec::new(), conditioning_transcript.clone(), - ) + )) .await - .expect("a final model is configured") .expect("transcript-conditioned final decode succeeds"); assert_eq!(conditioning_transcript, CONDITIONING_TRANSCRIPT); assert_eq!(conditioned_clip, CONDITIONED_CLIP_TRANSCRIPT); @@ -113,22 +138,22 @@ async fn packaged_runtime_preserves_native_transcription_contract() { let glossary_prompted = engine(library, model.clone(), Some(model.clone())); let glossary_clip = glossary_prompted - .transcribe_final( + .decode(request( + DecodeMode::Final, prompt_sensitive_clip, vec!["one tree".to_string()], - String::new(), - ) + "", + )) .await - .expect("a final model is configured") .expect("the glossary-conditioned segment decodes"); let silent_tail = glossary_prompted - .transcribe_final( + .decode(request( + DecodeMode::Final, vec![0.0; 16_000], vec!["one tree".to_string()], glossary_clip.clone(), - ) + )) .await - .expect("a final model is configured") .expect("the silent tail decodes"); assert!(silent_tail.is_empty(), "silence remains gated"); assert_eq!(glossary_clip, GLOSSARY_CLIP_TRANSCRIPT); @@ -149,14 +174,12 @@ async fn independent_final_jobs_do_not_require_a_reset() { let samples = jfk_samples(); let first = engine - .transcribe_final(samples.clone(), Vec::new(), String::new()) + .decode(request(DecodeMode::Final, samples.clone(), Vec::new(), "")) .await - .expect("a final model is configured") .expect("first job succeeds"); let second = engine - .transcribe_final(samples, Vec::new(), String::new()) + .decode(request(DecodeMode::Final, samples, Vec::new(), "")) .await - .expect("a final model is configured") .expect("second job succeeds"); assert_eq!(second, first, "equal stateless jobs remain independent"); } @@ -172,26 +195,37 @@ async fn one_final_job_cannot_change_another_jobs_history() { let prompt_sensitive = samples[6 * 16_000..8 * 16_000].to_vec(); let control = engine - .transcribe_final(prompt_sensitive.clone(), Vec::new(), String::new()) + .decode(request( + DecodeMode::Final, + prompt_sensitive.clone(), + Vec::new(), + "", + )) .await - .expect("a final model is configured") .expect("control job succeeds"); let history = engine - .transcribe_final(samples[..4 * 16_000].to_vec(), Vec::new(), String::new()) + .decode(request( + DecodeMode::Final, + samples[..4 * 16_000].to_vec(), + Vec::new(), + "", + )) .await - .expect("a final model is configured") .expect("history source succeeds"); let conditioned = engine - .transcribe_final(prompt_sensitive.clone(), Vec::new(), history) + .decode(request( + DecodeMode::Final, + prompt_sensitive.clone(), + Vec::new(), + history, + )) .await - .expect("a final model is configured") .expect("conditioned job succeeds"); assert_ne!(conditioned, control, "fixture detects conditioning"); let standalone = engine - .transcribe_final(prompt_sensitive, Vec::new(), String::new()) + .decode(request(DecodeMode::Final, prompt_sensitive, Vec::new(), "")) .await - .expect("a final model is configured") .expect("standalone job succeeds"); assert_eq!( standalone, control, @@ -206,12 +240,15 @@ async fn final_decode_is_absent_without_a_final_model() { let library = require_fixture("PROMPTFORGE_WHISPER_LIBRARY", "whisper.dll"); let model = require_fixture("PROMPTFORGE_WHISPER_MODEL", "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!( - engine - .transcribe_final(jfk_samples(), Vec::new(), String::new()) - .await - .is_none(), - "an omitted final model leaves no final decoder" + error + .to_string() + .contains("final decoder is not configured"), + "the missing final worker is classified explicitly: {error}" ); } diff --git a/crates/gateway-stt-engine/AGENTS.md b/crates/gateway-stt-engine/AGENTS.md index da5bd1bd..57913d57 100644 --- a/crates/gateway-stt-engine/AGENTS.md +++ b/crates/gateway-stt-engine/AGENTS.md @@ -4,4 +4,5 @@ This crate owns backend-neutral stateless transcription workers and shared audio - 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/module-ceilings.toml b/crates/gateway-stt-engine/module-ceilings.toml index 29cfc9b1..c8444a01 100644 --- a/crates/gateway-stt-engine/module-ceilings.toml +++ b/crates/gateway-stt-engine/module-ceilings.toml @@ -7,10 +7,12 @@ public_root_budget = 7 [migration_targets] [modules] -"decoder.rs" = 42 -"engine.rs" = 403 -"error.rs" = 77 +"decoder.rs" = 87 +"engine.rs" = 251 +"error.rs" = 83 "lib.rs" = 18 -"policy.rs" = 52 -"test_fixtures.rs" = 488 -"worker.rs" = 428 +"policy.rs" = 132 +"startup.rs" = 48 +"test_fixtures.rs" = 638 +"translation.rs" = 28 +"worker.rs" = 451 diff --git a/crates/gateway-stt-engine/src/decoder.rs b/crates/gateway-stt-engine/src/decoder.rs index ecb62fa1..9e73e7b8 100644 --- a/crates/gateway-stt-engine/src/decoder.rs +++ b/crates/gateway-stt-engine/src/decoder.rs @@ -4,39 +4,84 @@ 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. /// -/// Every call receives all guidance and finalized history needed for that -/// decode. Implementations must not retain request state between calls. +/// Implementations must not retain request state between calls. pub trait Decoder { /// Decodes one owned worker job. /// /// # Errors /// Returns a backend-translated transcription failure. - fn transcribe( - &mut self, - samples: &[f32], - guidance: &[String], - finalized: &str, - ) -> Result; + 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 required interim decoder. + /// Constructs the decoder for `mode`. /// - /// # Errors - /// Returns a backend-translated model construction failure. - fn create_interim(&self) -> Result, TranscribeError>; - - /// Constructs the optional final decoder. - /// - /// `None` means final-pass transcription is not configured. + /// `None` is valid only for an unconfigured final worker. /// /// # Errors /// Returns a backend-translated model construction failure. - fn create_final(&self) -> Result>, TranscribeError>; - - /// Whether the loaded backend reports hardware acceleration. - fn gpu_available(&self) -> bool; + 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 index 5dbde7d5..c436c111 100644 --- a/crates/gateway-stt-engine/src/engine.rs +++ b/crates/gateway-stt-engine/src/engine.rs @@ -1,85 +1,115 @@ //! Backend-neutral interim and final transcription workers. use std::sync::Arc; -use std::time::Duration; +use crate::startup; use crate::worker::{FINAL_JOB_CAPACITY, INTERIM_JOB_CAPACITY, Transcriber}; -use crate::{ModelFactory, SAMPLE_RATE, TranscribeError}; +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, - gpu_available: bool, - window_samples: usize, - interval: Duration, + policy: EnginePolicy, } impl SttEngine { /// Builds backend decoders on their owning worker threads. /// - /// `window_seconds` and `interval_ms` are backend-neutral capture policy. - /// /// # Errors - /// Returns [`TranscribeError::InvalidConfig`] for zero or overflowing - /// policy values, a backend-translated construction failure, or - /// [`TranscribeError::SpawnWorker`] when a worker cannot start. - pub fn new( + /// 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, - window_seconds: u64, - interval_ms: u64, + policy: EnginePolicy, + mut spawn: impl FnMut( + &'static str, + Arc, + DecodeMode, + usize, + ) -> Result< + ( + Transcriber, + std::sync::mpsc::Receiver>, + ), + TranscribeError, + >, ) -> 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(SAMPLE_RATE).ok_or_else(|| { - TranscribeError::InvalidConfig("stt.window_seconds is too large".to_owned()) - })?; - - let gpu_available = factory.gpu_available(); let factory: Arc = Arc::new(factory); - let (transcriber, interim_init) = Transcriber::spawn( + 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 (mut transcriber, interim_init) = spawn( "stt-interim", Arc::clone(&factory), - false, + DecodeMode::Interim, INTERIM_JOB_CAPACITY, )?; - let interim_exists = interim_init - .recv() - .map_err(|_| TranscribeError::WorkerGone)??; - if !interim_exists { - return Err(TranscribeError::InvalidConfig( - "the interim decoder is required".to_owned(), - )); - } - let (final_worker, final_init) = - Transcriber::spawn("stt-final", Arc::clone(&factory), true, FINAL_JOB_CAPACITY)?; - let final_pass = if final_init - .recv() - .map_err(|_| TranscribeError::WorkerGone)?? - { + let (mut 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, - gpu_available, - window_samples, - interval: Duration::from_millis(interval_ms), + policy, }) } @@ -92,48 +122,35 @@ impl SttEngine { /// Whether the backend reports hardware acceleration. #[must_use] pub fn gpu_transcription_available(&self) -> bool { - self.gpu_available + self.policy.gpu_available() } /// Samples in the sliding interim window. #[must_use] pub fn window_samples(&self) -> usize { - self.window_samples + self.policy.window_samples() } /// Cadence of the interim loop. #[must_use] - pub fn interval(&self) -> Duration { - self.interval + pub fn interval(&self) -> std::time::Duration { + self.policy.interval() } - /// Transcribes one interim audio buffer. + /// Decodes one explicit stateless request on its selected worker. /// /// # Errors - /// Returns a decoder failure or [`TranscribeError::WorkerGone`]. - pub async fn transcribe( - &self, - samples: Vec, - guidance: Vec, - ) -> Result { - self.transcriber - .transcribe(samples, guidance, String::new()) - .await - } - - /// Transcribes one independent buffer with the optional final decoder. - /// - /// # Errors - /// Returns a decoder failure or [`TranscribeError::WorkerGone`]. - pub async fn transcribe_final( - &self, - samples: Vec, - guidance: Vec, - finalized: String, - ) -> Option> { - match &self.final_pass { - Some(final_pass) => Some(final_pass.transcribe(samples, guidance, finalized).await), - None => None, + /// 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(), + )), + }, } } @@ -142,262 +159,93 @@ impl SttEngine { /// 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. - pub fn shutdown(&mut self) { - self.transcriber.shutdown(); - if let Some(final_pass) = &mut self.final_pass { - final_pass.shutdown(); + /// # 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(&mut 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) = &mut 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(()), } - self.final_pass = None; } } impl Drop for SttEngine { fn drop(&mut self) { - self.shutdown(); + // Explicit shutdown surfaces join panics. Drop cannot return one. + drop(self.shutdown()); } } #[cfg(test)] mod tests { - use std::path::PathBuf; - use std::sync::mpsc; - use std::thread::ThreadId; + use std::sync::{Arc, Barrier}; - use crate::{Decoder, ModelFactory}; + use crate::Decoder; use super::*; - #[derive(Debug)] - struct NeverFactory; - - impl ModelFactory for NeverFactory { - fn create_interim(&self) -> Result, TranscribeError> { - panic!("invalid policy must fail before backend construction"); - } - - fn create_final(&self) -> Result>, TranscribeError> { - panic!("invalid policy must fail before backend construction"); - } - - fn gpu_available(&self) -> bool { - false - } - } - - #[test] - fn zero_window_is_rejected_before_backend_construction() { - let error = SttEngine::new(NeverFactory, 0, 500).expect_err("zero window must fail"); - assert!(matches!(error, TranscribeError::InvalidConfig(_))); - } - - #[test] - fn zero_interval_is_rejected_before_backend_construction() { - let error = SttEngine::new(NeverFactory, 15, 0).expect_err("zero interval must fail"); - assert!(matches!(error, TranscribeError::InvalidConfig(_))); + fn policy() -> EnginePolicy { + EnginePolicy::new(15, 500, false).expect("test policy is valid") } #[derive(Debug)] - struct FailingModelFactory { - created: mpsc::Sender, - } - - impl ModelFactory for FailingModelFactory { - fn create_interim(&self) -> Result, TranscribeError> { - 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"), - )) - } - - fn create_final(&self) -> Result>, TranscribeError> { - Ok(None) - } + struct ConcurrentInterimFailure(Arc); - fn gpu_available(&self) -> bool { - false - } - } - - #[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, - }, - 15, - 500, - ) - .expect_err("model construction must fail"); - assert!(matches!(error, TranscribeError::LoadModel { .. })); - 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_interim(&self) -> Result, TranscribeError> { - Ok(Box::new(InterimDropProbe { - dropped: self.interim_dropped.clone(), - })) - } - - fn create_final(&self) -> Result>, TranscribeError> { + impl ModelFactory for ConcurrentInterimFailure { + fn create(&self, mode: DecodeMode) -> Result>, TranscribeError> { + assert_eq!(mode, DecodeMode::Interim); + self.0.wait(); Err(TranscribeError::InvalidConfig( - FINAL_INIT_SENTINEL.to_owned(), + "interim startup sentinel".to_owned(), )) } - - fn gpu_available(&self) -> bool { - false - } - } - - struct InterimDropProbe { - dropped: mpsc::Sender<()>, - } - - impl Decoder for InterimDropProbe { - fn transcribe( - &mut self, - _samples: &[f32], - _guidance: &[String], - _finalized: &str, - ) -> 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, + 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) }, - 15, - 500, ) - .expect_err("final model construction must fail"); - let TranscribeError::InvalidConfig(message) = error else { - panic!("the final worker's exact failure must reach the constructor"); - }; - assert_eq!(message, 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_interim(&self) -> Result, TranscribeError> { - let owner = std::thread::current().id(); - assert!( - self.events.send(WorkerEvent::Created(owner)).is_ok(), - "the test must receive decoder creation" - ); - Ok(Box::new(FailingDecoder { - owner, - events: self.events.clone(), - })) - } - - fn create_final(&self) -> Result>, TranscribeError> { - Ok(None) - } - - fn gpu_available(&self) -> bool { - false - } - } - - struct FailingDecoder { - owner: ThreadId, - events: mpsc::Sender, - } - - impl Decoder for FailingDecoder { - fn transcribe( - &mut self, - _samples: &[f32], - _guidance: &[String], - _finalized: &str, - ) -> 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 }, 15, 500) - .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 - .transcribe(vec![0.25; SAMPLE_RATE], Vec::new()) - .await - .expect_err("fake decode must fail"); - assert!(matches!(error, TranscribeError::Inference(_))); - 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"); + .expect_err("both concurrent startup failures prevent construction"); + let TranscribeError::StartupFailures { failures, .. } = error else { + panic!("both observed role failures must be aggregated"); }; - 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" - ); + 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 index ca9f00f9..026f864b 100644 --- a/crates/gateway-stt-engine/src/error.rs +++ b/crates/gateway-stt-engine/src/error.rs @@ -10,7 +10,6 @@ pub enum TranscribeError { #[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())] @@ -21,57 +20,64 @@ pub enum TranscribeError { #[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), } - -impl TranscribeError { - /// 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/lib.rs b/crates/gateway-stt-engine/src/lib.rs index 61ac7c2c..d7015e81 100644 --- a/crates/gateway-stt-engine/src/lib.rs +++ b/crates/gateway-stt-engine/src/lib.rs @@ -1,18 +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::{Decoder, ModelFactory}; +pub use decoder::{DecodeMode, DecodeRequest, Decoder, ModelFactory}; pub use engine::SttEngine; pub use error::TranscribeError; -pub use policy::{MIN_WINDOW_SAMPLES, SAMPLE_RATE, is_silence}; +pub use policy::EnginePolicy; diff --git a/crates/gateway-stt-engine/src/policy.rs b/crates/gateway-stt-engine/src/policy.rs index 2ab5b9fe..4e8a9770 100644 --- a/crates/gateway-stt-engine/src/policy.rs +++ b/crates/gateway-stt-engine/src/policy.rs @@ -1,13 +1,100 @@ -//! Backend-neutral audio policy shared by the engine and its host. +//! Backend-neutral worker and audio policy. -/// PCM sample rate the streaming wire format and decoders require. -pub const SAMPLE_RATE: usize = 16_000; +use std::time::Duration; -/// Minimum audio the interim loop bothers to transcribe. -pub const MIN_WINDOW_SAMPLES: usize = SAMPLE_RATE / 2; +use crate::TranscribeError; -/// Windows below this RMS are treated as silence. 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, @@ -21,13 +108,6 @@ fn rms(samples: &[f32]) -> f64 { (energy / samples.len() as f64).sqrt() } -/// Returns true when the buffer is quiet enough that a speech decoder would -/// hallucinate rather than transcribe. -#[must_use] -pub fn is_silence(samples: &[f32]) -> bool { - rms(samples) < SILENCE_RMS -} - #[cfg(test)] mod tests { use super::*; @@ -45,8 +125,8 @@ mod tests { #[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])); + 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 index 10e3162d..9d436eb7 100644 --- a/crates/gateway-stt-engine/src/test_fixtures.rs +++ b/crates/gateway-stt-engine/src/test_fixtures.rs @@ -5,7 +5,7 @@ use std::sync::{Arc, Condvar, Mutex, PoisonError}; use std::thread::ThreadId; use std::time::Duration; -use crate::{Decoder, ModelFactory, TranscribeError}; +use crate::{DecodeMode, DecodeRequest, Decoder, ModelFactory, TranscribeError}; #[derive(Debug)] enum ScriptedOutcome { @@ -23,15 +23,26 @@ enum ParkState { Released, } +#[derive(Debug, Default, Eq, PartialEq)] +enum ConstructionState { + #[default] + Ready, + Armed, + Parked, + Released, +} + #[derive(Debug, Default)] struct DecoderState { outcomes: VecDeque, - requests: Vec<(Vec, Vec, String)>, + requests: Vec, 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. @@ -71,6 +82,11 @@ impl ScriptedDecoder { self.state().park = ParkState::Armed; } + /// Parks decoder construction until [`Self::release_construction`] runs. + pub fn park_construction(&self) { + self.state().construction = ConstructionState::Armed; + } + /// Releases a decode parked by [`Self::park_next`]. pub fn release(&self) { let (_, changed) = &*self.shared; @@ -78,6 +94,18 @@ impl ScriptedDecoder { changed.notify_all(); } + /// Releases construction parked by [`Self::park_construction`]. + pub fn release_construction(&self) { + let (_, changed) = &*self.shared; + self.state().construction = ConstructionState::Released; + changed.notify_all(); + } + + /// 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 { @@ -90,9 +118,17 @@ impl ScriptedDecoder { self.wait_for(timeout, |state| state.park == ParkState::Parked) } - /// Returns all captured `(samples, guidance, finalized)` requests. + /// Waits until decoder construction has entered its rendezvous. + #[must_use] + pub fn wait_until_construction_parked(&self, timeout: Duration) -> bool { + self.wait_for(timeout, |state| { + state.construction == ConstructionState::Parked + }) + } + + /// Returns all captured stateless requests. #[must_use] - pub fn requests(&self) -> Vec<(Vec, Vec, String)> { + pub fn requests(&self) -> Vec { self.state().requests.clone() } @@ -134,24 +170,29 @@ impl ScriptedDecoder { } fn mark_created(&self) { - self.state().creation_thread = Some(std::thread::current().id()); + 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()); } } struct WorkerDecoder(ScriptedDecoder); impl Decoder for WorkerDecoder { - fn transcribe( - &mut self, - samples: &[f32], - guidance: &[String], - finalized: &str, - ) -> Result { + 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((samples.to_vec(), guidance.to_vec(), finalized.to_owned())); + state.requests.push(request); state.decode_threads.push(std::thread::current().id()); changed.notify_all(); if state.park == ParkState::Armed { @@ -176,8 +217,13 @@ impl Decoder for WorkerDecoder { impl Drop for WorkerDecoder { fn drop(&mut self) { let (_, changed) = &*self.0.shared; - self.0.state().worker_dropped = true; + 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"); } } @@ -249,39 +295,57 @@ impl ScriptedModelFactory { self.gpu_available = available; self } -} -impl ModelFactory for ScriptedModelFactory { - fn create_interim(&self) -> Result, TranscribeError> { - assert!(!self.panic_interim, "scripted interim factory panic"); - if let Some(message) = &self.interim_failure { - return Err(TranscribeError::InvalidConfig(message.clone())); - } - self.interim.mark_created(); - Ok(Box::new(WorkerDecoder(self.interim.clone()))) + /// Returns the fixture hardware-acceleration fact. + #[must_use] + pub fn gpu_available(&self) -> bool { + self.gpu_available } +} - fn create_final(&self) -> Result>, TranscribeError> { - assert!(!self.panic_final, "scripted final factory panic"); - if let Some(message) = &self.final_failure { +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) = &self.final_decoder else { + let Some(decoder) = decoder else { return Ok(None); }; decoder.mark_created(); Ok(Some(Box::new(WorkerDecoder(decoder.clone())))) } - - fn gpu_available(&self) -> bool { - self.gpu_available - } } #[cfg(test)] mod tests { use super::*; - use crate::SttEngine; + use crate::{EnginePolicy, SttEngine}; + + 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 { @@ -313,35 +377,47 @@ mod tests { ScriptedModelFactory::new(interim.clone()) .with_final(final_decoder.clone()) .with_gpu_available(true), - 15, - 500, + EnginePolicy::new(15, 500, true).expect("test policy is valid"), ) .expect("scripted workers start"); assert_eq!( engine - .transcribe(vec![0.25], vec!["term".to_owned()]) + .decode(request( + DecodeMode::Interim, + vec![0.25], + vec!["term".to_owned()], + "", + )) .await .expect("interim succeeds"), "interim" ); assert_eq!( engine - .transcribe_final(vec![0.5], vec!["name".to_owned()], "history".to_owned(),) + .decode(request( + DecodeMode::Final, + vec![0.5], + vec!["name".to_owned()], + "history", + )) .await - .expect("final worker exists") .expect("final succeeds"), "final" ); assert!(engine.gpu_transcription_available()); - assert_eq!( - interim.requests(), - vec![(vec![0.25], vec!["term".to_owned()], String::new())] - ); - assert_eq!( - final_decoder.requests(), - vec![(vec![0.5], vec!["name".to_owned()], "history".to_owned())] - ); + 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(), @@ -355,7 +431,7 @@ mod tests { .expect("final was constructed") ] ); - engine.shutdown(); + engine.shutdown().expect("workers join"); assert!(interim.worker_dropped()); assert!(final_decoder.worker_dropped()); } @@ -365,8 +441,7 @@ mod tests { let interim = ScriptedDecoder::new(); let error = SttEngine::new( ScriptedModelFactory::new(interim.clone()).with_interim_panic(), - 15, - 500, + policy(), ) .expect_err("startup panic fails construction"); assert!(matches!(error, TranscribeError::WorkerPanicked)); @@ -379,8 +454,7 @@ mod tests { let interim = ScriptedDecoder::new(); let error = SttEngine::new( ScriptedModelFactory::new(interim.clone()).with_final_panic(), - 15, - 500, + policy(), ) .expect_err("startup panic fails construction"); assert!(matches!(error, TranscribeError::WorkerPanicked)); @@ -391,15 +465,15 @@ mod tests { 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), 15, 500) + let engine = SttEngine::new(ScriptedModelFactory::new(interim), policy()) .expect("scripted worker starts"); let first = engine - .transcribe(Vec::new(), Vec::new()) + .decode(request(DecodeMode::Interim, Vec::new(), Vec::new(), "")) .await .expect_err("panic is reported"); assert!(matches!(first, TranscribeError::WorkerPanicked)); let second = engine - .transcribe(Vec::new(), Vec::new()) + .decode(request(DecodeMode::Interim, Vec::new(), Vec::new(), "")) .await .expect_err("panicked worker stays closed"); assert!(matches!(second, TranscribeError::WorkerGone)); @@ -412,18 +486,23 @@ mod tests { let waiter = std::thread::spawn(move || waiter_decoder.wait_for_requests(1, Duration::from_secs(1))); wait_until_waiter_is_registered(&interim); - let mut engine = SttEngine::new(ScriptedModelFactory::new(interim.clone()), 15, 500) + let mut engine = SttEngine::new(ScriptedModelFactory::new(interim.clone()), policy()) .expect("scripted worker starts"); engine - .transcribe(vec![0.25], vec!["term".to_owned()]) + .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(); + engine.shutdown().expect("worker joins"); assert!(interim.worker_dropped()); } @@ -433,10 +512,10 @@ mod tests { let interim = ScriptedDecoder::new(); interim.push_error(SENTINEL); - let mut engine = SttEngine::new(ScriptedModelFactory::new(interim.clone()), 15, 500) + let mut engine = SttEngine::new(ScriptedModelFactory::new(interim.clone()), policy()) .expect("scripted worker starts"); let error = engine - .transcribe(vec![0.25], Vec::new()) + .decode(request(DecodeMode::Interim, vec![0.25], Vec::new(), "")) .await .expect_err("scripted decode fails"); let TranscribeError::Inference(source) = error else { @@ -445,7 +524,7 @@ mod tests { assert_eq!(source.to_string(), SENTINEL); assert!(source.source().is_none()); - engine.shutdown(); + engine.shutdown().expect("worker joins"); assert!(interim.worker_dropped()); } @@ -456,8 +535,7 @@ mod tests { let interim = ScriptedDecoder::new(); let error = SttEngine::new( ScriptedModelFactory::new(interim.clone()).with_interim_failure(SENTINEL), - 15, - 500, + policy(), ) .expect_err("scripted interim construction fails"); assert_invalid_config(error, SENTINEL); @@ -475,8 +553,7 @@ mod tests { ScriptedModelFactory::new(interim.clone()) .with_final(final_decoder.clone()) .with_final_failure(SENTINEL), - 15, - 500, + policy(), ) .expect_err("scripted final construction fails"); assert_invalid_config(error, SENTINEL); @@ -485,4 +562,77 @@ mod tests { assert_eq!(final_decoder.creation_thread(), None); assert!(!final_decoder.worker_dropped()); } + + #[test] + fn parked_interim_construction_has_a_bounded_classified_outcome() { + let interim = ScriptedDecoder::new(); + interim.park_construction(); + let factory = ScriptedModelFactory::new(interim.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 || { + let result = SttEngine::new(factory, timeout); + drop(result_tx.send(result)); + }); + assert!(interim.wait_until_construction_parked(Duration::from_secs(1))); + let error = result_rx + .recv_timeout(Duration::from_secs(1)) + .expect("startup returns by its deadline") + .expect_err("parked interim construction times out"); + assert!(matches!(error, TranscribeError::InterimStartupTimedOut)); + constructor.join().expect("constructor does not panic"); + interim.release_construction(); + assert!(interim.wait_for(Duration::from_secs(1), |state| state.worker_dropped)); + } + + #[test] + fn parked_final_construction_cleans_up_the_initialized_interim_worker() { + let interim = ScriptedDecoder::new(); + let final_decoder = ScriptedDecoder::new(); + final_decoder.park_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 || { + let result = SttEngine::new(factory, timeout); + drop(result_tx.send(result)); + }); + assert!( + final_decoder.wait_until_construction_parked(Duration::from_secs(1)), + "final construction reaches its deterministic park" + ); + let error = result_rx + .recv_timeout(Duration::from_secs(1)) + .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_for(Duration::from_secs(1), |state| state.worker_dropped), + "the abandoned constructor releases its decoder after returning" + ); + } + + #[test] + fn shutdown_surfaces_join_panic_and_remains_idempotent() { + let interim = ScriptedDecoder::new(); + interim.panic_on_drop(); + let mut 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/translation.rs b/crates/gateway-stt-engine/src/translation.rs new file mode 100644 index 00000000..13efb5ce --- /dev/null +++ b/crates/gateway-stt-engine/src/translation.rs @@ -0,0 +1,28 @@ +//! Backend failure translation into engine-owned errors. + +use std::path::PathBuf; + +use crate::TranscribeError; + +impl TranscribeError { + /// 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 index d55d0610..d4a00410 100644 --- a/crates/gateway-stt-engine/src/worker.rs +++ b/crates/gateway-stt-engine/src/worker.rs @@ -4,15 +4,13 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, mpsc}; -use crate::{Decoder, ModelFactory, TranscribeError}; +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 { - samples: Vec, - guidance: Vec, - finalized: String, + request: DecodeRequest, reply: tokio::sync::oneshot::Sender>, } @@ -22,6 +20,7 @@ pub(crate) struct Transcriber { job_tx: Option>, stopping: Arc, worker: Option>, + join_panicked: bool, } impl Transcriber { @@ -29,7 +28,7 @@ impl Transcriber { pub(super) fn spawn( name: &'static str, factory: Arc, - final_model: bool, + mode: DecodeMode, capacity: usize, ) -> Result<(Self, mpsc::Receiver>), TranscribeError> { let (job_tx, job_rx) = mpsc::sync_channel::(capacity); @@ -39,13 +38,7 @@ impl Transcriber { let worker = std::thread::Builder::new() .name(name.to_owned()) .spawn(move || { - worker_loop( - factory.as_ref(), - final_model, - &job_rx, - &init_tx, - &worker_stopping, - ); + worker_loop(factory.as_ref(), mode, &job_rx, &init_tx, &worker_stopping); }) .map_err(TranscribeError::SpawnWorker)?; Ok(( @@ -53,6 +46,7 @@ impl Transcriber { job_tx: Some(job_tx), stopping, worker: Some(worker), + join_panicked: false, }, init_rx, )) @@ -60,9 +54,7 @@ impl Transcriber { fn submit( &self, - samples: Vec, - guidance: Vec, - finalized: String, + request: DecodeRequest, ) -> Result>, TranscribeError> { let (reply, reply_rx) = tokio::sync::oneshot::channel(); @@ -70,12 +62,7 @@ impl Transcriber { return Err(TranscribeError::WorkerGone); }; job_tx - .try_send(Job { - samples, - guidance, - finalized, - reply, - }) + .try_send(Job { request, reply }) .map_err(|error| match error { mpsc::TrySendError::Full(_) => TranscribeError::Overloaded, mpsc::TrySendError::Disconnected(_) => TranscribeError::WorkerGone, @@ -85,43 +72,67 @@ impl Transcriber { pub(super) async fn transcribe( &self, - samples: Vec, - guidance: Vec, - finalized: String, + request: DecodeRequest, ) -> Result { - let reply_rx = self.submit(samples, guidance, finalized)?; + let reply_rx = self.submit(request)?; reply_rx.await.map_err(|_| TranscribeError::WorkerGone)? } - pub(super) fn shutdown(&mut self) { + pub(super) fn shutdown(&mut self) -> Result<(), TranscribeError> { self.stopping.store(true, Ordering::Release); drop(self.job_tx.take()); if let Some(worker) = self.worker.take() { - let _ignored = worker.join(); + self.join_panicked = worker.join().is_err(); + } + if self.join_panicked { + Err(TranscribeError::ShutdownPanicked) + } else { + Ok(()) + } + } + + pub(super) fn abandon_startup(&mut self) { + self.stopping.store(true, Ordering::Release); + drop(self.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(self.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) { - self.shutdown(); + drop(self.shutdown()); } } fn worker_loop( factory: &dyn ModelFactory, - final_model: bool, + mode: DecodeMode, job_rx: &mpsc::Receiver, init_tx: &mpsc::SyncSender>, stopping: &AtomicBool, ) { - let decoder = catch_unwind(AssertUnwindSafe(|| { - if final_model { - factory.create_final() - } else { - factory.create_interim().map(Some) - } - })); + let decoder = catch_unwind(AssertUnwindSafe(|| factory.create(mode))); let decoder = match decoder { Ok(result) => result, Err(_) => Err(TranscribeError::WorkerPanicked), @@ -151,9 +162,7 @@ fn worker_loop( if job.reply.is_closed() { continue; } - let result = catch_unwind(AssertUnwindSafe(|| { - decoder.transcribe(&job.samples, &job.guidance, &job.finalized) - })); + 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. @@ -229,28 +238,18 @@ mod tests { struct ParkFactory(ParkControl); impl ModelFactory for ParkFactory { - fn create_interim(&self) -> Result, TranscribeError> { - Ok(Box::new(ParkDecoder(self.0.clone()))) - } - - fn create_final(&self) -> Result>, TranscribeError> { + fn create( + &self, + _mode: DecodeMode, + ) -> Result>, TranscribeError> { Ok(Some(Box::new(ParkDecoder(self.0.clone())))) } - - fn gpu_available(&self) -> bool { - false - } } struct ParkDecoder(ParkControl); - impl Decoder for ParkDecoder { - fn transcribe( - &mut self, - _samples: &[f32], - _guidance: &[String], - _finalized: &str, - ) -> Result { + impl crate::Decoder for ParkDecoder { + fn decode(&mut self, _request: DecodeRequest) -> Result { let (state, changed) = &*self.0.state; let mut state = state .lock() @@ -280,12 +279,15 @@ mod tests { } } - fn parked_worker(final_model: bool, capacity: usize) -> (Transcriber, ParkControl) { + 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, final_model, capacity) - .expect("worker spawns"); + let (worker, startup) = Transcriber::spawn("bounded-worker-test", factory, mode, capacity) + .expect("worker spawns"); assert!( startup .recv() @@ -295,10 +297,10 @@ mod tests { (worker, control) } - fn assert_queue_boundary(final_model: bool, capacity: usize) { - let (mut worker, control) = parked_worker(final_model, capacity); + fn assert_queue_boundary(mode: DecodeMode, capacity: usize) { + let (mut worker, control) = parked_worker(mode, capacity); let running = worker - .submit(Vec::new(), Vec::new(), String::new()) + .submit(request(mode)) .expect("running job is admitted"); control.wait_for( |state| state.phase == ParkPhase::Entered, @@ -308,12 +310,12 @@ mod tests { let queued = (0..capacity) .map(|_| { worker - .submit(Vec::new(), Vec::new(), String::new()) + .submit(request(mode)) .expect("every queue slot is admitted") }) .collect::>(); let error = worker - .submit(Vec::new(), Vec::new(), String::new()) + .submit(request(mode)) .expect_err("capacity plus one must fail without waiting"); assert!(matches!(error, TranscribeError::Overloaded)); @@ -326,27 +328,52 @@ mod tests { .expect("decode succeeds"), "scripted" ); - worker.shutdown(); + 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(false, INTERIM_JOB_CAPACITY); + 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(true, FINAL_JOB_CAPACITY); + 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 (mut 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 (mut worker, control) = parked_worker(false, INTERIM_JOB_CAPACITY); + let (mut worker, control) = parked_worker(DecodeMode::Interim, INTERIM_JOB_CAPACITY); let cancelled = worker - .submit(Vec::new(), Vec::new(), String::new()) + .submit(request(DecodeMode::Interim)) .expect("running job is admitted"); control.wait_for( |state| state.phase == ParkPhase::Entered, @@ -360,7 +387,7 @@ mod tests { ); let next = worker - .submit(Vec::new(), Vec::new(), String::new()) + .submit(request(DecodeMode::Interim)) .expect("worker remains available"); assert_eq!( next.blocking_recv() @@ -368,31 +395,27 @@ mod tests { .expect("decode succeeds"), "scripted" ); - worker.shutdown(); + worker.shutdown().expect("worker joins"); assert_eq!(control.calls(), 2); } #[test] fn shutdown_joins_the_worker_and_is_idempotent() { - let (mut worker, control) = parked_worker(false, INTERIM_JOB_CAPACITY); - worker.shutdown(); - worker.shutdown(); + let (mut 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(Vec::new(), Vec::new(), String::new()) - .is_err() - ); + assert!(worker.submit(request(DecodeMode::Interim)).is_err()); } #[test] fn shutdown_waits_for_running_decode_instead_of_detaching() { - let (worker, control) = parked_worker(false, INTERIM_JOB_CAPACITY); + let (worker, control) = parked_worker(DecodeMode::Interim, INTERIM_JOB_CAPACITY); let reply = worker - .submit(Vec::new(), Vec::new(), String::new()) + .submit(request(DecodeMode::Interim)) .expect("running job is admitted"); control.wait_for( |state| state.phase == ParkPhase::Entered, @@ -402,7 +425,7 @@ mod tests { let (returned_tx, returned_rx) = mpsc::channel(); let shutdown = std::thread::spawn(move || { let mut worker = worker; - worker.shutdown(); + worker.shutdown().expect("worker joins"); let _ignored = returned_tx.send(()); }); 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/startup_cleanup.rs b/crates/gateway-stt-engine/tests/startup_cleanup.rs new file mode 100644 index 00000000..a99caae3 --- /dev/null +++ b/crates/gateway-stt-engine/tests/startup_cleanup.rs @@ -0,0 +1,271 @@ +//! 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(); + interim.park_construction(); + let final_decoder = ScriptedDecoder::new(); + final_decoder.park_construction(); + let factory = ScriptedModelFactory::new(interim.clone()).with_final(final_decoder.clone()); + let policy = policy().with_startup_timeout(Duration::from_millis(200)); + let (result_tx, result_rx) = std::sync::mpsc::channel(); + let constructor = std::thread::spawn(move || { + let result = SttEngine::new(factory, policy); + drop(result_tx.send(result)); + }); + + assert!( + interim.wait_until_construction_parked(Duration::from_secs(1)), + "interim construction reaches its rendezvous" + ); + assert!( + final_decoder.wait_until_construction_parked(Duration::from_secs(1)), + "final construction starts before the interim outcome is available" + ); + let result = result_rx.recv_timeout(Duration::from_millis(350)); + interim.release_construction(); + final_decoder.release_construction(); + constructor.join().expect("constructor does not panic"); + + let error = result + .expect("both outcomes share the original 200 ms deadline") + .expect_err("both parked workers time out"); + 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(mut 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(mut 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(mut 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/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index 7c10b1c3..93694d01 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -23,8 +23,8 @@ destination = "independent committed-item finalization" [modules] "api.rs" = 625 "lib.rs" = 23 -"runtime.rs" = 454 -"segment.rs" = 233 -"stt.rs" = 712 -"take.rs" = 663 -"test_fixtures.rs" = 69 +"runtime.rs" = 459 +"segment.rs" = 239 +"stt.rs" = 733 +"take.rs" = 677 +"test_fixtures.rs" = 73 diff --git a/crates/gateway-stt/src/api.rs b/crates/gateway-stt/src/api.rs index 6289d545..ada16038 100644 --- a/crates/gateway-stt/src/api.rs +++ b/crates/gateway-stt/src/api.rs @@ -4,7 +4,7 @@ use std::io::Cursor; use axum::extract::Multipart; use axum::response::{IntoResponse, Response}; -use gateway_stt_engine::SAMPLE_RATE; +use gateway_stt_engine::{DecodeMode, DecodeRequest, EnginePolicy}; use serde::Serialize; use crate::runtime::{LoadedModelRole, SttState}; @@ -106,14 +106,14 @@ pub async fn transcribe( return Err(TranscriptionError::ModelNotFound(form.model)); }; let (samples, duration) = decode_wav(&form.file)?; - let text = match role { - LoadedModelRole::Interim => engine.transcribe(samples, guidance).await, - LoadedModelRole::Final => engine - .transcribe_final(samples, guidance, String::new()) - .await - .ok_or_else(|| TranscriptionError::ModelNotFound(form.model.clone()))?, - } - .map_err(TranscriptionError::Inference)?; + let mode = match role { + LoadedModelRole::Interim => DecodeMode::Interim, + LoadedModelRole::Final => DecodeMode::Final, + }; + let text = engine + .decode(DecodeRequest::new(mode, samples, guidance, String::new())) + .await + .map_err(TranscriptionError::Inference)?; Ok(axum::Json(response(form, text, duration)).into_response()) } @@ -240,7 +240,7 @@ fn decode_wav(bytes: &[u8]) -> Result<(Vec, f64), TranscriptionError> { .collect::, _>>()? } }; - let duration = samples.len() as f64 / SAMPLE_RATE as f64; + let duration = samples.len() as f64 / EnginePolicy::SAMPLE_RATE as f64; Ok((samples, duration)) } @@ -357,7 +357,6 @@ impl TranscriptionError { matches!(self, Self::Inference(_)) } } - #[cfg(test)] mod tests { use super::*; @@ -366,7 +365,10 @@ mod tests { use axum::http::{Request, StatusCode}; use axum::routing::post; use tower::ServiceExt; - + mod native_runtime { + #[rustfmt::skip] + include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/common/native_runtime.rs")); + } fn wav(samples: &[i16]) -> Vec { let mut bytes = Cursor::new(Vec::new()); { @@ -387,7 +389,6 @@ mod tests { } bytes.into_inner() } - fn wav_f32(samples: &[f32]) -> Vec { let mut bytes = Cursor::new(Vec::new()); { @@ -492,7 +493,6 @@ mod tests { 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(), @@ -583,7 +583,7 @@ mod tests { .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 runtime = native_runtime::start(config, state.clone()); let samples = crate::test_fixtures::jfk_samples(); let (boundary, body) = multipart_body( &wav_f32(&samples), @@ -620,6 +620,6 @@ mod tests { .is_some_and(|text| text.to_lowercase().contains("country")) ); assert_eq!(json["segments"][0]["start"], 0.0); - runtime.shutdown(); + native_runtime::shutdown(runtime); } } diff --git a/crates/gateway-stt/src/runtime.rs b/crates/gateway-stt/src/runtime.rs index 980561fb..91d8bce5 100644 --- a/crates/gateway-stt/src/runtime.rs +++ b/crates/gateway-stt/src/runtime.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, PoisonError, RwLock}; use gateway_config::{Config, SttRole}; use gateway_local::artifacts::ArtifactStore; use gateway_stt_backend_whisper::{WhisperConfig, WhisperModelFactory}; -use gateway_stt_engine::SttEngine; +use gateway_stt_engine::{EnginePolicy, SttEngine}; use shared_progress::ProgressHandle; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -220,8 +220,13 @@ impl SttRuntime { progress.map(|handle| handle.child("engine", 1.0)), ); let factory = WhisperModelFactory::new(backend_config).map_err(SttRuntimeError::Engine)?; - let engine = SttEngine::new(factory, capture.window_seconds(), capture.interval_ms()) - .map_err(SttRuntimeError::Engine)?; + let policy = EnginePolicy::new( + capture.window_seconds(), + capture.interval_ms(), + factory.gpu_available(), + ) + .map_err(SttRuntimeError::Engine)?; + let engine = SttEngine::new(factory, policy).map_err(SttRuntimeError::Engine)?; let final_name = models.final_model.map(|(name, _)| name); state.activate(engine, interim_name, final_name, guidance); Ok(SttRuntime { diff --git a/crates/gateway-stt/src/segment.rs b/crates/gateway-stt/src/segment.rs index 0aad62e3..e53f9738 100644 --- a/crates/gateway-stt/src/segment.rs +++ b/crates/gateway-stt/src/segment.rs @@ -11,20 +11,20 @@ use std::ops::Range; -use gateway_stt_engine::{SAMPLE_RATE, is_silence}; +use gateway_stt_engine::EnginePolicy; /// 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. /// @@ -70,7 +70,7 @@ impl Segmenter { pub 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); @@ -107,12 +107,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. @@ -157,11 +157,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,7 +181,13 @@ 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(), diff --git a/crates/gateway-stt/src/stt.rs b/crates/gateway-stt/src/stt.rs index 1cc36aa5..e10fd724 100644 --- a/crates/gateway-stt/src/stt.rs +++ b/crates/gateway-stt/src/stt.rs @@ -10,7 +10,7 @@ use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::http::{HeaderMap, StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum::routing::get; -use gateway_stt_engine::{MIN_WINDOW_SAMPLES, SAMPLE_RATE, SttEngine, is_silence}; +use gateway_stt_engine::{DecodeMode, DecodeRequest, EnginePolicy, SttEngine}; use serde::Serialize; use tokio::sync::{mpsc, watch}; use workshop_server::{Activity, Push}; @@ -281,7 +281,9 @@ fn spawn_interim( loop { tokio::time::sleep(engine.interval()).await; let window = state.uncommitted_snapshot(engine.window_samples()); - let tentative = if window.len() < MIN_WINDOW_SAMPLES || is_silence(&window) { + let tentative = if window.len() < EnginePolicy::MIN_WINDOW_SAMPLES + || EnginePolicy::is_silence(&window) + { String::new() } else { reporter.push_activity( @@ -289,7 +291,15 @@ fn spawn_interim( "an interim pass over the uncommitted audio", Activity::General, ); - match engine.transcribe(window, state.guidance().to_vec()).await { + match engine + .decode(DecodeRequest::new( + DecodeMode::Interim, + window, + state.guidance().to_vec(), + String::new(), + )) + .await + { Ok(text) => text, Err(error) => { reporter.push_activity( @@ -328,10 +338,18 @@ async fn final_transcript( reporter: &Reporter, ) -> String { let window = take.fallback_snapshot(engine.window_samples()); - if window.len() < MIN_WINDOW_SAMPLES || is_silence(&window) { + if window.len() < EnginePolicy::MIN_WINDOW_SAMPLES || EnginePolicy::is_silence(&window) { return String::new(); } - match engine.transcribe(window, take.guidance().to_vec()).await { + match engine + .decode(DecodeRequest::new( + DecodeMode::Interim, + window, + take.guidance().to_vec(), + String::new(), + )) + .await + { Ok(text) => text, Err(error) => { reporter.push_failure("Transcription failed", error.to_string(), Activity::General); @@ -356,9 +374,9 @@ fn truncation_drop(uncommitted: usize, window_samples: usize) -> Option { 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, + window_samples / EnginePolicy::SAMPLE_RATE, + dropped / EnginePolicy::SAMPLE_RATE, + dropped % EnginePolicy::SAMPLE_RATE * 10 / EnginePolicy::SAMPLE_RATE, ) } @@ -672,19 +690,22 @@ mod tests { #[test] fn truncation_starts_past_the_window() { - let window = 15 * SAMPLE_RATE; + let window = 15 * EnginePolicy::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) + truncation_drop(20 * EnginePolicy::SAMPLE_RATE, window), + Some(5 * EnginePolicy::SAMPLE_RATE) ); } #[test] fn the_truncation_message_names_the_window_and_the_dropped_lead() { - let message = truncation_message(15 * SAMPLE_RATE, 5 * SAMPLE_RATE); + let message = truncation_message( + 15 * EnginePolicy::SAMPLE_RATE, + 5 * EnginePolicy::SAMPLE_RATE, + ); assert!(message.contains("15 s"), "{message}"); assert!(message.contains("5.0 s"), "{message}"); } diff --git a/crates/gateway-stt/src/take.rs b/crates/gateway-stt/src/take.rs index 45206dd7..8a44e77f 100644 --- a/crates/gateway-stt/src/take.rs +++ b/crates/gateway-stt/src/take.rs @@ -368,7 +368,21 @@ fn spawn_final_pipeline( state, move |samples, guidance, finalized| { let engine = Arc::clone(&engine); - async move { engine.transcribe_final(samples, guidance, finalized).await } + async move { + if !engine.has_final_pass() { + return None; + } + Some( + engine + .decode(gateway_stt_engine::DecodeRequest::new( + gateway_stt_engine::DecodeMode::Final, + samples, + guidance, + finalized, + )) + .await, + ) + } }, )); FinalPipeline { commands, task } diff --git a/crates/gateway-stt/src/test_fixtures.rs b/crates/gateway-stt/src/test_fixtures.rs index b06ac2ff..a6395682 100644 --- a/crates/gateway-stt/src/test_fixtures.rs +++ b/crates/gateway-stt/src/test_fixtures.rs @@ -3,13 +3,15 @@ #[cfg(test)] use std::path::{Path, PathBuf}; +#[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")] use crate::SttRuntime; #[cfg(feature = "test-fixtures")] -use gateway_stt_engine::{SttEngine, TranscribeError}; +use gateway_stt_engine::{EnginePolicy, SttEngine, TranscribeError}; /// Builds a speech runtime around deterministic scripted workers. /// @@ -21,7 +23,9 @@ pub fn scripted_runtime( window_seconds: u64, interval_ms: u64, ) -> Result { - let engine = SttEngine::new(factory, window_seconds, interval_ms)?; + let gpu_available = factory.gpu_available(); + let policy = EnginePolicy::new(window_seconds, interval_ms, gpu_available)?; + let engine = SttEngine::new(factory, policy)?; let final_name = engine.has_final_pass().then(|| "scripted-final".to_owned()); Ok(SttRuntime::from_scripted_engine( engine, 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..89f58b9d --- /dev/null +++ b/crates/gateway-stt/tests/common/native_runtime.rs @@ -0,0 +1,33 @@ +// 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::{SttRuntime, SttState}; + +pub(crate) fn start(config: gateway_config::Config, state: SttState) -> SttRuntime { + let (result_tx, result_rx) = std::sync::mpsc::channel(); + let startup = std::thread::spawn(move || { + drop(result_tx.send(SttRuntime::start(&config, state, None))); + }); + let runtime = 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"); + runtime +} + +pub(crate) fn shutdown(runtime: SttRuntime) { + let (finished_tx, finished_rx) = std::sync::mpsc::channel(); + let shutdown = std::thread::spawn(move || { + runtime.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/it/legacy_stream.rs b/crates/gateway-stt/tests/it/legacy_stream.rs index b19f420a..b0d5ab35 100644 --- a/crates/gateway-stt/tests/it/legacy_stream.rs +++ b/crates/gateway-stt/tests/it/legacy_stream.rs @@ -1,4 +1,5 @@ //! Characterization tests for the mechanically moved `/stt` socket. +//! Miri excludes these OS socket tests; native CI owns their coverage. #![expect( clippy::expect_used, @@ -9,7 +10,7 @@ use std::time::Duration; use futures_util::{SinkExt as _, StreamExt as _}; use gateway_stt::Segmenter; -use gateway_stt_engine::{MIN_WINDOW_SAMPLES, SAMPLE_RATE}; +use gateway_stt_engine::EnginePolicy; use serde_json::json; use tokio_tungstenite::tungstenite; use tokio_tungstenite::tungstenite::client::IntoClientRequest; @@ -23,10 +24,14 @@ use crate::common::{ #[test] fn legacy_stream_policy_constants_stay_pinned() { let capture = gateway_config::WorkshopSttConfig::default(); - assert_eq!(SAMPLE_RATE, 16_000, "wire PCM stays at 16 kHz"); assert_eq!( - MIN_WINDOW_SAMPLES, - SAMPLE_RATE / 2, + EnginePolicy::SAMPLE_RATE, + 16_000, + "wire PCM stays at 16 kHz" + ); + assert_eq!( + EnginePolicy::MIN_WINDOW_SAMPLES, + EnginePolicy::SAMPLE_RATE / 2, "interim decoding still requires half a second" ); assert_eq!( @@ -65,9 +70,9 @@ async fn closed_segments_are_reported_in_input_order() { let speech = jfk_samples(); let third = speech.len() / 3; let mut samples = speech[..third].to_vec(); - samples.extend(vec![0.0; 3 * SAMPLE_RATE]); + samples.extend(vec![0.0; 3 * EnginePolicy::SAMPLE_RATE]); samples.extend_from_slice(&speech[2 * third..]); - samples.extend(vec![0.0; 3 * SAMPLE_RATE]); + samples.extend(vec![0.0; 3 * EnginePolicy::SAMPLE_RATE]); let mut segmenter = Segmenter::new(); let mut ranges = Vec::new(); while let Some(range) = segmenter.poll(&samples) { @@ -448,7 +453,7 @@ async fn a_disconnected_client_does_not_break_the_next_final_take() { abandoned.send_text("start").await; assert_eq!(abandoned.recv_json().await["type"], "stream"); send_samples(&mut abandoned, &jfk_samples()).await; - send_pcm(&mut abandoned, 3 * SAMPLE_RATE).await; + send_pcm(&mut abandoned, 3 * EnginePolicy::SAMPLE_RATE).await; abandoned.close().await; let mut survivor = JsonSocket::connect(&server.ws_url("/stt")).await; diff --git a/crates/gateway-whisper-ffi/module-ceilings.toml b/crates/gateway-whisper-ffi/module-ceilings.toml index 22083164..6b430154 100644 --- a/crates/gateway-whisper-ffi/module-ceilings.toml +++ b/crates/gateway-whisper-ffi/module-ceilings.toml @@ -9,7 +9,7 @@ public_root_budget = 6 [modules] "context.rs" = 226 "error.rs" = 114 -"lib.rs" = 79 +"lib.rs" = 80 "library.rs" = 204 "log.rs" = 116 "params.rs" = 189 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/src/test_support.rs b/crates/gateway/src/test_support.rs index 0771f4e4..5881897e 100644 --- a/crates/gateway/src/test_support.rs +++ b/crates/gateway/src/test_support.rs @@ -199,9 +199,14 @@ mod tests { 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!( - decoder.requests(), - vec![(vec![0.25], Vec::new(), String::new())] + 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()); } } diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index cfcf719c..a276920e 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -20,6 +20,9 @@ todos: - 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 isProject: false --- @@ -39,11 +42,12 @@ isProject: false - 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. + - 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 28 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. @@ -217,6 +221,7 @@ isProject: false - 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. - 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. @@ -331,7 +336,7 @@ Use Windows PowerShell 5.1. Every command below has an explicit working director The architecture harness enforces exact workspace-package edges across normal, development, and build dependencies, ignoring self-dependencies. Phase A, Steps 7 through 20: `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 21 through 25, adds only `workshop-server -> shared-loopback`. Final Phase C, Step 26 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 - c6198001 +### 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. @@ -339,7 +344,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 - f7afccf6 +### 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. @@ -347,7 +352,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 - d743690b +### 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. @@ -356,7 +361,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 - e2c8dcc3 +### 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. @@ -367,7 +372,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 - dff68665 +### 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. @@ -377,7 +382,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 - 5acbd7ed +### 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. @@ -390,7 +395,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 - 0cbeb1b2 +### 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. @@ -402,7 +407,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 - fb3a5be9 +### 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. @@ -414,7 +419,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. @@ -627,7 +632,17 @@ The architecture harness enforces exact workspace-package edges across normal, d - `C:\Users\Vinnie\cursor\promptforge`: `mdbook build guide` - Consumes and gates: consumes Step 26 final topology; final verification starts only with zero temporary exceptions. -### Step 28: Run every release gate and repeat acceptance +### Step 28: Bookend Gateway serving logs + +- 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 27 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 29's full release verification must pass after this change. + +### Step 29: Run every release gate and repeat acceptance - Artifacts: append command results, hashes, ratchet counts, native equivalence, generated-doc cleanliness, and repeated installed-microphone evidence to `design/generic-realtime-stt-acceptance.md`; change no implementation. - Scope: run every exit criterion independently under PowerShell 5.1 and stop on any failure. @@ -661,6 +676,6 @@ The architecture harness enforces exact workspace-package edges across normal, d - `C:\Users\Vinnie\cursor\promptforge\crates\workshop`: `if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY)) { throw 'TAURI_SIGNING_PRIVATE_KEY is required by the release workflow' }; if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD)) { throw 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD is required by the release workflow' }; $env:RUSTUP_TOOLCHAIN='stable'; cargo tauri build --bundles nsis` - `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 27, then repeats the Step 25 installed-package microphone scenarios. Completion requires every command, ratchet, generated-doc check, and physical scenario to pass with no open finding. +- Consumes and gates: consumes Step 28, then repeats the Step 25 installed-package microphone scenarios. 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, or completed Workshop baseline-ratchet work. VAD tuning, alignment, 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 +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 28's exact two records, or completed Workshop baseline-ratchet work. VAD tuning, alignment, 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/archdoc-next.md b/vibe/archdoc-next.md index 30fadcbf..5394cf66 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -128,16 +128,16 @@ - [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 -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 +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 N6 | observation | oversized-unit @ crates/gateway-stt/src/take.rs: adds a 651-line take module | Move take ownership into gateway STT 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 -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 -N10 | observation | Violates A96 @ crates/gateway-stt/src/api.rs: not determinable from diff | Move take ownership into gateway STT +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 +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 +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 @@ -146,9 +146,10 @@ N17 | observation | Violates A2 @ crates/gateway-stt/tests/it/architecture.rs: n 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 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 -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 -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 +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 +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 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 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 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 +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 From 3642d6dda6a98d29e89f0753e67c8849e14df287 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 06:54:34 -0700 Subject: [PATCH 17/86] Migrate STT tuning to canonical configuration Move speech pipeline tuning to a canonical top-level configuration while preserving legacy input during parsing. Reject mixed canonical and legacy forms, serialize only the canonical form, and apply tuning changes without a restart. - `SttPipelineConfig` keeps validated window, interval, and vocabulary state private and exposes read-only access. The `stt` accessor replaces speech tuning access through `WorkshopConfig`. - `migrate_legacy_stt` accepts legacy input only when canonical input is absent, and `canonicalizeStt` prevents browser saves from writing the legacy shape. Validation tests cover zero bounds, conflicting forms, serialization, hot apply, and UI persistence. Design: new value-object @ crates/gateway-config/src/config/stt.rs::SttPipelineConfig boundary: pub Design: new encapsulated-invariant @ crates/gateway-config/src/config/stt.rs::SttPipelineConfig boundary: pub Design: new surface-growth @ crates/gateway-config/src/config/accessors.rs::Config::stt boundary: pub Design: new shim @ crates/gateway-config/src/config/imp.rs::migrate_legacy_stt deps: &mut toml::Value boundary: persisted Design: new stringly-typed @ crates/gateway-config/src/config/imp.rs::migrate_legacy_stt deps: &mut toml::Value boundary: persisted Design: new shim @ crates/gateway-config-ui/ui/src/services/config-store.ts::canonicalizeStt deps: EntryData boundary: persisted Design: new stringly-typed @ crates/gateway-config-ui/ui/src/services/config-store.ts::canonicalizeStt deps: EntryData boundary: persisted Violates: A2 - credential ownership in SttRuntime is not determinable from diff Violates: A96 - bounded third-party model content in canonicalizeStt is not determinable from diff Violates: A115 - control readiness in SttRuntime is not determinable from diff Violates: A116 - publication consistency in stt_pipeline_change_reloads_without_restart is not determinable from diff Pending: N9 - compounds Pending: N27 - compounds Deferred: gateway configuration test module registration is absent Deferred: generated guide summary and index updates are absent Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- .../ui/src/services/config-store.ts | 30 ++- .../ui/src/views/settings-sections.test.mjs | 65 ++++-- .../ui/src/views/settings-view.ts | 142 +++++-------- crates/gateway-config/README.md | 23 ++- crates/gateway-config/src/config.rs | 31 ++- crates/gateway-config/src/config/accessors.rs | 13 +- crates/gateway-config/src/config/imp.rs | 37 +++- crates/gateway-config/src/config/stt.rs | 157 ++++++++++++++- .../gateway-config/src/config/tests/schema.rs | 18 ++ .../src/config/tests/serialize.rs | 20 +- .../src/config/tests/validation.rs | 37 ++++ crates/gateway-config/src/config/workshop.rs | 186 +----------------- crates/gateway-config/src/lib.rs | 4 +- crates/gateway-stt/src/runtime.rs | 6 +- crates/gateway-stt/tests/common/mod.rs | 2 +- crates/gateway-stt/tests/it/legacy_stream.rs | 2 +- crates/gateway/AGENTS.md | 2 +- crates/gateway/README.md | 6 +- crates/gateway/src/config_apply.rs | 27 +++ crates/gateway/src/runner.rs | 11 +- gateway.local.example.toml | 7 +- guide/promptforge-gateway-guide.md | 8 +- guide/promptforge-workshop-guide.md | 8 +- guide/src/gateway/05-speech.md | 6 +- guide/src/gateway/10-serving-and-observing.md | 2 +- guide/src/workshop/01-application.md | 2 +- guide/src/workshop/07-voice.md | 6 +- vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- vibe/archdoc-next.md | 6 +- 29 files changed, 506 insertions(+), 360 deletions(-) diff --git a/crates/gateway-config-ui/ui/src/services/config-store.ts b/crates/gateway-config-ui/ui/src/services/config-store.ts index c37a18b9..65172d25 100644 --- a/crates/gateway-config-ui/ui/src/services/config-store.ts +++ b/crates/gateway-config-ui/ui/src/services/config-store.ts @@ -178,8 +178,8 @@ export class ConfigStore { this.api.getStatus(), this.loadChatTemplates(), ]); - this.running = running; - this.pending = pending; + this.running = canonicalizeStt(running); + this.pending = canonicalizeStt(pending); this.dirty = dirty; this.orphans = visibleOrphans(orphans); this.cache = cache; @@ -201,7 +201,7 @@ export class ConfigStore { this.api.getConfigDirty(), this.loadChatTemplates(), ]); - this.pending = pending; + this.pending = canonicalizeStt(pending); this.dirty = dirty; this.chatTemplates = chatTemplates; } @@ -221,7 +221,7 @@ export class ConfigStore { /** Re-reads the running view too (after apply/revert). */ private async refreshAll(): Promise { const [running, status] = await Promise.all([this.api.getConfig(), this.api.getStatus()]); - this.running = running; + this.running = canonicalizeStt(running); this.activeProfile = status.profile; this.runningModels = status.models; await Promise.all([this.refreshPending(), this.refreshArtifacts()]); @@ -401,10 +401,12 @@ export class ConfigStore { /** * The full `PUT /admin/config` payload base. Untouched secrets remain - * `"***"` so the gateway can restore them before validation. + * `"***"` so the gateway can restore them before validation. A legacy + * `workshop.stt` value is moved to canonical top-level `stt` before any + * browser save. */ buildConfigPayload(): EntryData { - return structuredClone(this.pending); + return canonicalizeStt(structuredClone(this.pending)); } /** Stages the global config and optional active-profile shadow. */ @@ -807,6 +809,22 @@ function modelArray(kind: ModelSource): "model" | "local_model" | "stt_model" { return kind === "local" ? "local_model" : "stt_model"; } +/** Moves legacy `workshop.stt` input into the canonical top-level section. */ +function canonicalizeStt(config: EntryData): EntryData { + const workshop = config["workshop"]; + if (!isRecord(workshop) || !isRecord(workshop["stt"])) { + return config; + } + if (!isRecord(config["stt"])) { + config["stt"] = workshop["stt"]; + } + delete workshop["stt"]; + if (Object.keys(workshop).length === 0) { + delete config["workshop"]; + } + return config; +} + /** Removes ArtifactStore marker files from a gateway response defensively. */ function visibleOrphans(orphans: OrphanFile[]): OrphanFile[] { return orphans.filter((orphan) => !orphan.path.toLocaleLowerCase().endsWith(".verified")); 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..526f32bc 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,66 @@ 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 legacy Workshop STT payload is saved only as canonical STT", async () => { + const config = modelsFixture(); + config.workshop = { + 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(); + 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 +576,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 +598,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..e92674ae 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]]` + +Legacy `[workshop.stt]` input migrates to `[stt]` only when the canonical section is absent. Defining both is rejected, and every serialized configuration uses only `[stt]`. `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. 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..9d2a9ff3 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,15 @@ impl Config { self.tools.as_ref() } + /// Returns canonical `[stt]` pipeline tuning, or `None` when absent. + /// + /// Legacy `[workshop.stt]` input is migrated to this accessor during + /// parsing and is never exposed through [`WorkshopConfig`]. + #[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..51dadb41 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(), } } @@ -225,17 +226,45 @@ impl Config { /// parsed TOML document. pub(crate) fn from_value(mut document: toml::Value) -> Result { interpolate_value(&mut document)?; + migrate_legacy_stt(&mut document)?; let raw: RawConfig = document.try_into().map_err(|source| ConfigError::Parse { 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) } } +fn migrate_legacy_stt(document: &mut toml::Value) -> Result<(), ConfigError> { + let Some(root) = document.as_table_mut() else { + return Ok(()); + }; + let legacy = root + .get_mut("workshop") + .and_then(toml::Value::as_table_mut) + .and_then(|workshop| workshop.remove("stt")); + let Some(legacy) = legacy else { + return Ok(()); + }; + if root.contains_key("stt") { + return Err(ConfigError::Validation( + "[stt] and [workshop.stt] cannot both be present".to_owned(), + )); + } + root.insert("stt".to_owned(), legacy); + if root + .get("workshop") + .and_then(toml::Value::as_table) + .is_some_and(toml::map::Map::is_empty) + { + root.remove("workshop"); + } + Ok(()) +} + pub(crate) fn reject_profiles_directory(path: &Path) -> Result<(), ConfigError> { let profiles = path .parent() @@ -320,7 +349,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 +357,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..5bb2dc64 100644 --- a/crates/gateway-config/src/config/tests/schema.rs +++ b/crates/gateway-config/src/config/tests/schema.rs @@ -74,6 +74,24 @@ fn canonical_example_uses_the_validated_section_layout() { assert_eq!(selected.stt_models().len(), 2); } +#[test] +fn canonical_and_legacy_stt_sections_share_one_runtime_shape() { + let canonical = Config::from_toml_str(&format!( + "{CATALOG}\n[stt]\nwindow_seconds = 8\ninterval_ms = 250\nvocabulary = [\"WG21\"]\n" + )) + .expect("canonical STT section parses"); + let legacy = Config::from_toml_str(&format!( + "{CATALOG}\n[workshop.stt]\nwindow_seconds = 8\ninterval_ms = 250\nvocabulary = [\"WG21\"]\n" + )) + .expect("legacy STT section migrates"); + + assert_eq!(canonical.stt(), legacy.stt()); + let stt = canonical.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..08b6e5a3 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 legacy_stt_input_serializes_only_as_canonical_stt() { + let config = Config::from_toml_str( + "config-version = 2\n[server]\nbind = \"127.0.0.1:8081\"\napi_key = \"k\"\n\ + [workshop.stt]\nwindow_seconds = 8\n", + ) + .expect("legacy 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..1920f231 100644 --- a/crates/gateway-config/src/config/tests/validation.rs +++ b/crates/gateway-config/src/config/tests/validation.rs @@ -196,6 +196,43 @@ fn parses_config_without_tools_section() { assert!(config.tools.is_none()); } +#[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::from_toml_str(toml), + Err(error) if error.kind() == crate::ConfigErrorKind::Validation + )); +} + +#[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-stt/src/runtime.rs b/crates/gateway-stt/src/runtime.rs index 91d8bce5..7e9895a3 100644 --- a/crates/gateway-stt/src/runtime.rs +++ b/crates/gateway-stt/src/runtime.rs @@ -207,11 +207,7 @@ impl SttRuntime { 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 capture = config.stt().cloned().unwrap_or_default(); let guidance = capture.vocabulary().to_vec(); let backend_config = WhisperConfig::new( library, diff --git a/crates/gateway-stt/tests/common/mod.rs b/crates/gateway-stt/tests/common/mod.rs index e8e4e9d6..896628b9 100644 --- a/crates/gateway-stt/tests/common/mod.rs +++ b/crates/gateway-stt/tests/common/mod.rs @@ -99,7 +99,7 @@ fn fixture_runtime_with_models_on_dedicated_thread( "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]\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" )) diff --git a/crates/gateway-stt/tests/it/legacy_stream.rs b/crates/gateway-stt/tests/it/legacy_stream.rs index b0d5ab35..716b2918 100644 --- a/crates/gateway-stt/tests/it/legacy_stream.rs +++ b/crates/gateway-stt/tests/it/legacy_stream.rs @@ -23,7 +23,7 @@ use crate::common::{ #[test] fn legacy_stream_policy_constants_stay_pinned() { - let capture = gateway_config::WorkshopSttConfig::default(); + let capture = gateway_config::SttPipelineConfig::default(); assert_eq!( EnginePolicy::SAMPLE_RATE, 16_000, diff --git a/crates/gateway/AGENTS.md b/crates/gateway/AGENTS.md index 82c213a6..6ab0e1a0 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 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; never fail or silently ignore it. diff --git a/crates/gateway/README.md b/crates/gateway/README.md index c7361d52..33adfdce 100644 --- a/crates/gateway/README.md +++ b/crates/gateway/README.md @@ -95,7 +95,7 @@ Four feature flags exist: - `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. - `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. 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 because its `bind` and `open_browser` settings are inert. ### Speech-to-text models @@ -121,10 +121,12 @@ 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. +Legacy `[workshop.stt]` input is accepted only when `[stt]` is absent. Defining both is an error, and config serialization writes only `[stt]`. + | Field | Default | Meaning | |---|---|---| | `window_seconds` | `15` | Seconds of trailing audio each interim pass transcribes. | diff --git a/crates/gateway/src/config_apply.rs b/crates/gateway/src/config_apply.rs index 62cfec28..feffd0f4 100644 --- a/crates/gateway/src/config_apply.rs +++ b/crates/gateway/src/config_apply.rs @@ -672,6 +672,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/runner.rs b/crates/gateway/src/runner.rs index 110d4dd9..d269a2bb 100644 --- a/crates/gateway/src/runner.rs +++ b/crates/gateway/src/runner.rs @@ -1185,15 +1185,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 +1357,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/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 de0af62a..1f2511a2 100644 --- a/guide/promptforge-gateway-guide.md +++ b/guide/promptforge-gateway-guide.md @@ -409,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"] @@ -420,6 +420,8 @@ 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. +Legacy `[workshop.stt]` input is accepted only when `[stt]` is absent. Defining both is rejected, and saved configuration uses only `[stt]`. + ## The transcription endpoint 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[]`. @@ -758,7 +760,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. The section keeps parsing - an existing config must not fail - and the gateway logs a deprecation warning at startup because its `bind` and `open_browser` settings are inert. Speech pipeline tuning belongs in `[stt]`; legacy `[workshop.stt]` input migrates only when `[stt]` is absent. ## Manage the cache diff --git a/guide/promptforge-workshop-guide.md b/guide/promptforge-workshop-guide.md index 9912048d..4795b4fc 100644 --- a/guide/promptforge-workshop-guide.md +++ b/guide/promptforge-workshop-guide.md @@ -60,7 +60,7 @@ The generated config is a single editable TOML file with a header that invites e - 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. It still parses: the gateway logs a deprecation warning, and its `bind` and `open_browser` settings do nothing because the Workshop's server now lives inside the desktop application. Speech pipeline tuning belongs in `[stt]`; legacy `[workshop.stt]` input migrates only when `[stt]` is absent. 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. @@ -513,10 +513,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 +527,8 @@ You can add a `vocabulary` list of domain terms to bias recognition: vocabulary = ["MCP", "GGUF", "Lua"] ```` +Legacy `[workshop.stt]` input is accepted only when `[stt]` is absent. The gateway saves only the canonical `[stt]` form. + 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/05-speech.md b/guide/src/gateway/05-speech.md index 722c9f6c..22caed79 100644 --- a/guide/src/gateway/05-speech.md +++ b/guide/src/gateway/05-speech.md @@ -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,6 +32,8 @@ 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. +Legacy `[workshop.stt]` input is accepted only when `[stt]` is absent. Defining both is rejected, and saved configuration uses only `[stt]`. + ## The transcription endpoint 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[]`. diff --git a/guide/src/gateway/10-serving-and-observing.md b/guide/src/gateway/10-serving-and-observing.md index 6f909b3f..6228c096 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. The section keeps parsing - an existing config must not fail - and the gateway logs a deprecation warning at startup because its `bind` and `open_browser` settings are inert. Speech pipeline tuning belongs in `[stt]`; legacy `[workshop.stt]` input migrates only when `[stt]` is absent. ## Manage the cache diff --git a/guide/src/workshop/01-application.md b/guide/src/workshop/01-application.md index 725a1c66..628ed938 100644 --- a/guide/src/workshop/01-application.md +++ b/guide/src/workshop/01-application.md @@ -56,7 +56,7 @@ The generated config is a single editable TOML file with a header that invites e - 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. It still parses: the gateway logs a deprecation warning, and its `bind` and `open_browser` settings do nothing because the Workshop's server now lives inside the desktop application. Speech pipeline tuning belongs in `[stt]`; legacy `[workshop.stt]` input migrates only when `[stt]` is absent. 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..57462728 100644 --- a/guide/src/workshop/07-voice.md +++ b/guide/src/workshop/07-voice.md @@ -45,10 +45,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 +59,8 @@ You can add a `vocabulary` list of domain terms to bias recognition: vocabulary = ["MCP", "GGUF", "Lua"] ```` +Legacy `[workshop.stt]` input is accepted only when `[stt]` is absent. The gateway saves only the canonical `[stt]` form. + 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/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index a276920e..bf62e2df 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -431,7 +431,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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`. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 5394cf66..396e6f48 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -134,7 +134,7 @@ N5 | observation | shared-mutable-state @ crates/gateway-stt/src/take.rs::TakeSt N6 | observation | oversized-unit @ crates/gateway-stt/src/take.rs: adds a 651-line take module | Move take ownership into gateway STT 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 -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 +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 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 @@ -152,4 +152,6 @@ N23 | observation | feature-flag @ crates/gateway-stt/Cargo.toml::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 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 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 -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 +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 From 4b49007377c5e800a5fcb16bd14008ecc9d8dcb2 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 07:30:59 -0700 Subject: [PATCH 18/86] Add bounded realtime audio ingestion Decode canonical Base64 PCM audio into one continuous resampled stream for realtime transcription. Enforce append size, buffered duration, sample integrity, and minimum commit limits before downstream decoding. - `AudioBuffer` owns odd-byte carry, input duration, and the `Resampler24To16` timeline. A successful `commit` flushes the final output position and resets all ingestion state. - `decode_base64` rejects malformed, noncanonical, and oversized input. `pcm16le-24khz.json` pins language-neutral little-endian bytes for other consumers. - `audio` remains private under `allow(dead_code)` and has no runtime caller in the touched files. Design: new oversized-unit @ crates/gateway-stt/src/audio.rs Design: new pure-function @ crates/gateway-stt/src/audio.rs::decode_base64 deps: str Design: new value-object @ crates/gateway-stt/src/audio.rs::CommittedAudio Violates: A2 - crates/gateway-stt/src/audio.rs does not determine credential ownership Pending: N6 - compounds Pending: N9 - compounds Deferred: Realtime session wiring remains absent from this commit Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- Cargo.lock | 1 + Cargo.toml | 1 + crates/gateway-stt/Cargo.toml | 1 + crates/gateway-stt/module-ceilings.toml | 3 +- crates/gateway-stt/src/audio.rs | 379 ++++++++++++++++++ crates/gateway-stt/src/lib.rs | 2 + .../tests/fixtures/audio/pcm16le-24khz.json | 8 + vibe/2026-09-05-2-generic-realtime-stt.md | 4 +- vibe/archdoc-next.md | 4 +- 9 files changed, 398 insertions(+), 5 deletions(-) create mode 100644 crates/gateway-stt/src/audio.rs create mode 100644 crates/gateway-stt/tests/fixtures/audio/pcm16le-24khz.json diff --git a/Cargo.lock b/Cargo.lock index f0c5aafe..6e42db34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1996,6 +1996,7 @@ name = "gateway-stt" version = "0.2.0" dependencies = [ "axum", + "base64 0.22.1", "futures-util", "gateway-config", "gateway-local", diff --git a/Cargo.toml b/Cargo.toml index b600cbff..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" } diff --git a/crates/gateway-stt/Cargo.toml b/crates/gateway-stt/Cargo.toml index 0456fbc2..a2b7b95b 100644 --- a/crates/gateway-stt/Cargo.toml +++ b/crates/gateway-stt/Cargo.toml @@ -11,6 +11,7 @@ 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 diff --git a/crates/gateway-stt/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index 93694d01..ed71eade 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -22,7 +22,8 @@ destination = "independent committed-item finalization" [modules] "api.rs" = 625 -"lib.rs" = 23 +"audio.rs" = 380 +"lib.rs" = 25 "runtime.rs" = 459 "segment.rs" = 239 "stt.rs" = 733 diff --git a/crates/gateway-stt/src/audio.rs b/crates/gateway-stt/src/audio.rs new file mode 100644 index 00000000..9f12b06e --- /dev/null +++ b/crates/gateway-stt/src/audio.rs @@ -0,0 +1,379 @@ +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 + } + + 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(()) + } + + pub(super) fn commit(&mut self) -> Result { + 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, + }); + } + + self.resampler.flush(); + let samples = std::mem::take(&mut self.resampler.output); + let input_samples = self.input_samples; + self.clear(); + Ok(CommittedAudio { + samples, + input_samples, + }) + } + + pub(super) fn clear(&mut self) { + *self = Self::default(); + } + + #[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, +} + +impl Resampler24To16 { + fn push(&mut self, sample: f32) { + let input_twice = self.input_index * 2; + if self.next_output_twice == input_twice { + self.output.push(sample); + self.next_output_twice += 3; + } else if self.next_output_twice < input_twice { + let previous = self.previous.unwrap_or(sample); + self.output.push(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.output.push(previous); + self.next_output_twice += 3; + } + debug_assert_eq!( + self.output.len(), + (self.input_index * OUTPUT_SAMPLE_RATE).div_ceil(INPUT_SAMPLE_RATE) + ); + } +} + +#[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/lib.rs b/crates/gateway-stt/src/lib.rs index 6439ce33..f89d3ea4 100644 --- a/crates/gateway-stt/src/lib.rs +++ b/crates/gateway-stt/src/lib.rs @@ -8,6 +8,8 @@ //! and [`transcribe`] implements OpenAI-compatible multipart transcription. mod api; +#[allow(dead_code)] +mod audio; mod runtime; mod segment; mod stt; 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/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index bf62e2df..a6779279 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -446,13 +446,13 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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` + - `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` diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 396e6f48..86aef6d9 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -131,10 +131,10 @@ N2 | observation | Violates A96 @ crates/workshop-server/ui/test/realtime-wire-f 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 -N6 | observation | oversized-unit @ crates/gateway-stt/src/take.rs: adds a 651-line take module | Move take ownership into gateway STT +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 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 -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 +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 From 101dedaca694cfaa1707ac0e0c86257fb420676c Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 07:56:23 -0700 Subject: [PATCH 19/86] Define the private Realtime wire Define private client and server events for realtime transcription. Reject unsupported fields and invalid shapes before state changes, preserve atomic session updates, and issue independent opaque identifiers. Record CI repairs and align migration deadlines with the expanded schedule. - `ClientEvent` and `ServerEvent` encode the accepted event families as crate-private types with strict shape checks. - `NEXT_GENERATOR` allocates generator namespaces from process-wide atomic state, and `parse_empty` selects commit or clear behavior through a Boolean parameter. - `apply_update_text` clones the effective session and publishes only a fully valid update. `validate` accepts only the exact transcription query. - `canonical_client_events_parse_and_updates_are_atomic` and `canonical_server_events_round_trip_with_exact_shapes` pin atomic updates and exact fixture parity. - `realtime` remains private and intentionally unwired to a socket route. Design: new pure-function @ crates/gateway-stt/src/realtime/query.rs::validate deps: Option Design: new dispatch-on-tag @ crates/gateway-stt/src/realtime/wire/client.rs::parse_client_event deps: str Design: new pure-function @ crates/gateway-stt/src/realtime/wire/client.rs::parse_client_event deps: str Design: new pure-function @ crates/gateway-stt/src/realtime/wire/client.rs::correlation deps: Map Design: new pure-function @ crates/gateway-stt/src/realtime/wire/client.rs::required_string deps: Correlation,Map,str,str Design: new pure-function @ crates/gateway-stt/src/realtime/wire/client.rs::reject_unknown deps: Correlation,Map,str,str Design: new pure-function @ crates/gateway-stt/src/realtime/wire/client.rs::object_at deps: Correlation,Value,str Design: new pure-function @ crates/gateway-stt/src/realtime/wire/client.rs::parse_append deps: Correlation,Map Design: new flag-parameter @ crates/gateway-stt/src/realtime/wire/client.rs::parse_empty deps: Correlation,Map,bool Design: new pure-function @ crates/gateway-stt/src/realtime/wire/client.rs::parse_empty deps: Correlation,Map,bool Design: new pure-function @ crates/gateway-stt/src/realtime/wire/client.rs::client_id deps: Correlation Design: new pure-function @ crates/gateway-stt/src/realtime/wire/client.rs::parse_update deps: Correlation,Map Design: new pure-function @ crates/gateway-stt/src/realtime/wire/client.rs::parse_audio deps: Correlation,Value Design: new pure-function @ crates/gateway-stt/src/realtime/wire/client.rs::parse_format deps: Correlation,Value Design: new pure-function @ crates/gateway-stt/src/realtime/wire/client.rs::parse_transcription deps: Correlation,Value Design: new pure-function @ crates/gateway-stt/src/realtime/wire/client.rs::parse_include deps: Correlation,Value Design: new stringly-typed @ crates/gateway-stt/src/realtime/wire/shared.rs::ClientEvent Design: new pure-function @ crates/gateway-stt/src/realtime/wire/shared.rs::deserialize_required_nullable deps: D Design: new global-state @ crates/gateway-stt/src/realtime/wire/shared.rs::NEXT_GENERATOR Design: new stringly-typed @ crates/gateway-stt/src/realtime/wire/server.rs::EffectiveSession Design: new stringly-typed @ crates/gateway-stt/src/realtime/wire/server.rs::ServerEvent Design: new stringly-typed @ crates/gateway-stt/src/realtime/wire/server.rs::ConversationItem Design: new stringly-typed @ crates/gateway-stt/src/realtime/wire/server.rs::WireError Design: new oversized-unit @ crates/gateway-stt/src/realtime/wire/server.rs::ServerEvent::validate Design: new pure-function @ crates/gateway-stt/src/realtime/wire/server.rs::validate_id deps: str Design: new pure-function @ crates/gateway-stt/src/realtime/wire/server.rs::validate_optional_id deps: Option Violates: A2 - not determinable from diff Deferred: Realtime socket integration remains unwired Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- crates/gateway-stt/module-ceilings.toml | 17 +- crates/gateway-stt/src/lib.rs | 2 + crates/gateway-stt/src/realtime/mod.rs | 2 + crates/gateway-stt/src/realtime/query.rs | 70 ++++ crates/gateway-stt/src/realtime/wire.rs | 24 ++ .../gateway-stt/src/realtime/wire/client.rs | 363 +++++++++++++++++ .../gateway-stt/src/realtime/wire/server.rs | 367 ++++++++++++++++++ .../gateway-stt/src/realtime/wire/shared.rs | 212 ++++++++++ crates/gateway-stt/src/realtime/wire/tests.rs | 278 +++++++++++++ crates/gateway-stt/tests/it/architecture.rs | 14 +- vibe/2026-09-05-2-generic-realtime-stt.md | 94 +++-- vibe/archdoc-next.md | 4 + 12 files changed, 1403 insertions(+), 44 deletions(-) create mode 100644 crates/gateway-stt/src/realtime/mod.rs create mode 100644 crates/gateway-stt/src/realtime/query.rs create mode 100644 crates/gateway-stt/src/realtime/wire.rs create mode 100644 crates/gateway-stt/src/realtime/wire/client.rs create mode 100644 crates/gateway-stt/src/realtime/wire/server.rs create mode 100644 crates/gateway-stt/src/realtime/wire/shared.rs create mode 100644 crates/gateway-stt/src/realtime/wire/tests.rs diff --git a/crates/gateway-stt/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index ed71eade..67baf0e3 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -5,25 +5,32 @@ public_root_budget = 9 [migration_targets."api.rs"] -target_step = "Step 15" +target_step = "Step 17" destination = "batch.rs" [migration_targets."runtime.rs"] -target_step = "Step 15" +target_step = "Step 17" destination = "service.rs, artifacts.rs, generation.rs, status.rs, and model.rs" [migration_targets."stt.rs"] -target_step = "Step 26" +target_step = "Step 28" destination = "removal after the Realtime route and Workshop relay replace the legacy socket" [migration_targets."take.rs"] -target_step = "Step 14" +target_step = "Step 16" destination = "independent committed-item finalization" [modules] "api.rs" = 625 "audio.rs" = 380 -"lib.rs" = 25 +"lib.rs" = 28 +"realtime/mod.rs" = 2 +"realtime/query.rs" = 70 +"realtime/wire.rs" = 24 +"realtime/wire/client.rs" = 363 +"realtime/wire/server.rs" = 367 +"realtime/wire/shared.rs" = 212 +"realtime/wire/tests.rs" = 278 "runtime.rs" = 459 "segment.rs" = 239 "stt.rs" = 733 diff --git a/crates/gateway-stt/src/lib.rs b/crates/gateway-stt/src/lib.rs index f89d3ea4..bbae25d5 100644 --- a/crates/gateway-stt/src/lib.rs +++ b/crates/gateway-stt/src/lib.rs @@ -10,6 +10,8 @@ mod api; #[allow(dead_code)] mod audio; +#[allow(dead_code)] +mod realtime; mod runtime; mod segment; mod stt; diff --git a/crates/gateway-stt/src/realtime/mod.rs b/crates/gateway-stt/src/realtime/mod.rs new file mode 100644 index 00000000..0b94c461 --- /dev/null +++ b/crates/gateway-stt/src/realtime/mod.rs @@ -0,0 +1,2 @@ +mod query; +mod wire; 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/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..d9ff1aae --- /dev/null +++ b/crates/gateway-stt/src/realtime/wire/server.rs @@ -0,0 +1,367 @@ +use serde::{Deserialize, Serialize}; +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, +}; + +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(in crate::realtime) 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(()) + } + + 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(in crate::realtime) 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(in crate::realtime) 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(in crate::realtime) struct DurationUsage { + r#type: String, + seconds: f64, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(in crate::realtime) 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 from_value(value: Value) -> Result { + let event: Self = serde_json::from_value(value).map_err(|error| error.to_string())?; + event.validate()?; + Ok(event) + } + + 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 { + 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 { + 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 { + fn has_event_id(&self) -> bool { + !self.event_id.is_missing() + } + + 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(()) + } +} + +fn validate_id(id: &str) -> Result<(), String> { + if id.is_empty() { + Err("opaque ID must not be empty".to_owned()) + } else { + Ok(()) + } +} + +fn validate_optional_id(id: Option<&str>) -> Result<(), String> { + id.map_or(Ok(()), validate_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..ecd5cb47 --- /dev/null +++ b/crates/gateway-stt/src/realtime/wire/shared.rs @@ -0,0 +1,212 @@ +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(in crate::realtime) struct ClientError { + 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 { + 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("invalid_request_error")); + 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}) + } +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub(in crate::realtime) enum RequiredNullable { + Null, + Value(T), +} + +impl RequiredNullable { + pub(super) fn as_ref(&self) -> Option<&T> { + match self { + Self::Null => None, + Self::Value(value) => Some(value), + } + } + + 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) + } + + 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) + } + + 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/tests/it/architecture.rs b/crates/gateway-stt/tests/it/architecture.rs index 0dcf8d6e..3ee375c1 100644 --- a/crates/gateway-stt/tests/it/architecture.rs +++ b/crates/gateway-stt/tests/it/architecture.rs @@ -75,7 +75,7 @@ const DEPENDENCY_POLICIES: [DependencyPolicy; 7] = [ ], temporary_edges: &[TemporaryEdge { dependency: "workshop-server", - removal_step: "Step 26", + removal_step: "Step 28", }], }, DependencyPolicy { @@ -124,22 +124,22 @@ const MIGRATION_POLICIES: [MigrationPolicy; 4] = [ targets: &[ MigrationPolicyTarget { module: "api.rs", - target_step: "Step 15", + target_step: "Step 17", destination: "batch.rs", }, MigrationPolicyTarget { module: "runtime.rs", - target_step: "Step 15", + target_step: "Step 17", destination: "service.rs, artifacts.rs, generation.rs, status.rs, and model.rs", }, MigrationPolicyTarget { module: "stt.rs", - target_step: "Step 26", + target_step: "Step 28", destination: "removal after the Realtime route and Workshop relay replace the legacy socket", }, MigrationPolicyTarget { module: "take.rs", - target_step: "Step 14", + target_step: "Step 16", destination: "independent committed-item finalization", }, ], @@ -512,14 +512,14 @@ fn gateway_step_15_migrations_are_pinned_to_their_destinations() { assert_eq!( expected["api.rs"], MigrationTarget { - target_step: "Step 15".to_owned(), + target_step: "Step 17".to_owned(), destination: "batch.rs".to_owned(), } ); assert_eq!( expected["runtime.rs"], MigrationTarget { - target_step: "Step 15".to_owned(), + target_step: "Step 17".to_owned(), destination: "service.rs, artifacts.rs, generation.rs, status.rs, and model.rs" .to_owned(), } diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index a6779279..b67cb93a 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -23,6 +23,12 @@ todos: - 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: pending + - id: ci-workshop-sidecars + content: Stage target-named Gateway sidecars before Windows and Linux Workshop CI builds + status: pending isProject: false --- @@ -47,7 +53,7 @@ isProject: false - 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 28 are the sole logging exception. + - 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 30 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. @@ -222,6 +228,8 @@ isProject: false - 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. - 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. @@ -334,7 +342,7 @@ isProject: false 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 20: `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 21 through 25, adds only `workshop-server -> shared-loopback`. Final Phase C, Step 26 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. +The architecture harness enforces exact workspace-package edges across normal, development, and build dependencies, ignoring self-dependencies. Phase A, Steps 7 through 22: `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 23 through 27, adds only `workshop-server -> shared-loopback`. Final Phase C, Step 28 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] @@ -459,7 +467,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. @@ -470,7 +478,29 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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: Own sessions and uncommitted input +### Step 13: Isolate architecture tools from ambient stable + +- 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 + +- 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 - 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. @@ -481,7 +511,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 14: Finalize committed items independently +### Step 16: Finalize committed items independently - 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. @@ -490,9 +520,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 13; complete item ownership gates facade replacement and generation quiescence. +- Consumes and gates: consumes Step 15; complete item ownership gates facade replacement and generation quiescence. -### Step 15: Replace runtime and route APIs atomically +### Step 17: Replace runtime and route APIs atomically - 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`. @@ -504,7 +534,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `C:\Users\Vinnie\cursor\promptforge`: `cargo test -p gateway-stt --test it architecture` - Consumes and gates: consumes Steps 10 and 14; every current reverse consumer compiles and tests in this API-changing commit. -### Step 16: Quiesce generations with explicit ownership +### Step 18: Quiesce generations with explicit ownership - 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. @@ -515,7 +545,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 17: Make profile replacement transactional +### Step 19: Make profile replacement transactional - 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. @@ -524,9 +554,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 16; cancellation-at-every-await and rollback outcomes gate route mounting. +- Consumes and gates: consumes Step 18; cancellation-at-every-await and rollback outcomes gate route mounting. -### Step 18: Separate origin predicates +### Step 20: Separate origin predicates - 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. @@ -534,7 +564,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 19: Integrate generic speech facts +### Step 21: Integrate generic speech facts - 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. @@ -544,9 +574,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 15 facade and Step 17 lifecycle; status correctness gates route publication. +- Consumes and gates: consumes Step 17 facade and Step 19 lifecycle; status correctness gates route publication. -### Step 20: Mount the additive Gateway route +### Step 22: Mount the additive Gateway route - 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. @@ -554,27 +584,27 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 19; the independent Gateway fixture path gates Workshop relay work. +- Consumes and gates: consumes Steps 12 through 21; the independent Gateway fixture path gates Workshop relay work. -### Step 21: Add the Workshop relay beside legacy +### Step 23: Add the Workshop relay beside legacy - 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 18 Workshop predicate and Step 20 public fixtures, but adds no dependency on Gateway or gateway-stt. +- Consumes and gates: consumes Step 20 Workshop predicate and Step 22 public fixtures, but adds no dependency on Gateway or gateway-stt. -### Step 22: Prove the actual worklet bytes +### Step 24: Prove the actual worklet bytes - 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 21 additive relay; byte parity gates browser migration. +- Consumes and gates: consumes Step 11 language-neutral bytes and Step 23 additive relay; byte parity gates browser migration. -### Step 23: Migrate Workshop browser speech +### Step 25: Migrate Workshop browser speech - 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. @@ -583,7 +613,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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, 21, and 22; browser acceptance gates independent full-path automation. -### Step 24: Prove both fixture-driven halves +### Step 26: Prove both fixture-driven halves - 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. @@ -591,9 +621,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 20 through 23; both independent halves must pass before packaging. +- Consumes and gates: consumes Steps 22 through 25; both independent halves must pass before packaging. -### Step 25: Pass installed Windows microphone acceptance +### Step 27: Pass installed Windows microphone acceptance - 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` steps `Build and stage the gateway sidecar`, `Build the app`, and `Install and check (Windows)`, then record installed-package microphone revision, completion, second take, clear, cancellation, and recoverable permission or device failure with binary hashes and timestamps. @@ -604,9 +634,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `C:\Users\Vinnie\cursor\promptforge\crates\workshop`: `if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY)) { throw 'TAURI_SIGNING_PRIVATE_KEY is required by the release workflow' }; if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD)) { throw 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD is required by the release workflow' }; $env:RUSTUP_TOOLCHAIN='stable'; cargo tauri build --bundles nsis` - `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 24; the operator must exercise the installed application and record passing evidence, which alone gates legacy removal. +- Consumes and gates: consumes Step 26; the operator must exercise the installed application and record passing evidence, which alone gates legacy removal. -### Step 26: Remove legacy seams and tests +### Step 28: Remove legacy seams and tests - 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 Step 3, 20, 21, 23, or 24 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. @@ -620,7 +650,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 27: Finalize architecture and documentation +### Step 29: Finalize architecture and documentation - 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. @@ -630,9 +660,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 26 final topology; final verification starts only with zero temporary exceptions. +- Consumes and gates: consumes Step 28 final topology; final verification starts only with zero temporary exceptions. -### Step 28: Bookend Gateway serving logs +### Step 30: Bookend Gateway serving logs - 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. @@ -640,9 +670,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 27 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 29's full release verification must pass after this change. +- Consumes and gates: this operator-requested observability correction is independent of STT and follows Step 29 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 31's full release verification must pass after this change. -### Step 29: Run every release gate and repeat acceptance +### Step 31: Run every release gate and repeat acceptance - Artifacts: append command results, hashes, ratchet counts, native equivalence, generated-doc cleanliness, and repeated installed-microphone evidence to `design/generic-realtime-stt-acceptance.md`; change no implementation. - Scope: run every exit criterion independently under PowerShell 5.1 and stop on any failure. @@ -676,6 +706,6 @@ The architecture harness enforces exact workspace-package edges across normal, d - `C:\Users\Vinnie\cursor\promptforge\crates\workshop`: `if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY)) { throw 'TAURI_SIGNING_PRIVATE_KEY is required by the release workflow' }; if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD)) { throw 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD is required by the release workflow' }; $env:RUSTUP_TOOLCHAIN='stable'; cargo tauri build --bundles nsis` - `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 28, then repeats the Step 25 installed-package microphone scenarios. Completion requires every command, ratchet, generated-doc check, and physical scenario to pass with no open finding. +- Consumes and gates: consumes Step 30, then repeats the Step 27 installed-package microphone scenarios. 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 28's exact two records, or completed Workshop baseline-ratchet work. VAD tuning, alignment, 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 +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 30's exact two records, or completed Workshop baseline-ratchet work. VAD tuning, alignment, 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/archdoc-next.md b/vibe/archdoc-next.md index 86aef6d9..c5a451a3 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -155,3 +155,7 @@ N26 | observation | constructor-injection @ crates/gateway-stt/src/runtime.rs::S 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 +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 From 04e887d0bb0d21741ee920f8dc76ea8aee233a18 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 08:22:42 -0700 Subject: [PATCH 20/86] Pin STT architecture tools to Rust 1.89 Run architecture checks with the repository-supported Cargo release so ambient stable updates cannot break the pinned tools. Install the matching toolchain in continuous integration, force every architecture child process to use it, and fail closed when the Cargo release or a required tool is unavailable. - `runCargo` passes a copied environment with `RUSTUP_TOOLCHAIN` fixed to `1.89`, and its injected `spawn` and `env` inputs make child selection testable. - `requireCargoVersion` rejects other Cargo releases before module or public API checks run. - `tools/check-stt-architecture.test.mjs` pins ambient Cargo 1.98 rejection, the child environment for both tools, and failures for an absent toolchain or command. - `.github/workflows/ci.yml` installs Rust 1.89 and builds both pinned architecture tools with it while the job keeps stable as its default. Design: new surface-growth @ tools/check-stt-architecture.mjs::requireCargoVersion deps: output boundary: pub Design: new pure-function @ tools/check-stt-architecture.mjs::requireCargoVersion deps: output boundary: pub Design: new surface-growth @ tools/check-stt-architecture.mjs::runCargo deps: args,env,root,spawn boundary: pub Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- .github/workflows/ci.yml | 6 +- tools/check-stt-architecture.mjs | 19 +++++- tools/check-stt-architecture.test.mjs | 75 +++++++++++++++++++++++ vibe/2026-09-05-2-generic-realtime-stt.md | 4 +- 4 files changed, 98 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cda4d46..3aef157b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,8 @@ jobs: with: components: rustfmt, clippy + - uses: dtolnay/rust-toolchain@1.89 + - name: Cache cargo uses: Swatinem/rust-cache@v2 @@ -43,8 +45,8 @@ jobs: - name: Install architecture tools run: | - cargo install cargo-modules --version 0.25.0 --locked - cargo install cargo-public-api --version 0.52.0 --locked + RUSTUP_TOOLCHAIN=1.89 cargo install cargo-modules --version 0.25.0 --locked + RUSTUP_TOOLCHAIN=1.89 cargo install cargo-public-api --version 0.52.0 --locked - name: Install UI dependencies working-directory: crates/workshop-server/ui diff --git a/tools/check-stt-architecture.mjs b/tools/check-stt-architecture.mjs index 8810d628..6be87d71 100644 --- a/tools/check-stt-architecture.mjs +++ b/tools/check-stt-architecture.mjs @@ -5,6 +5,8 @@ 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 RUSTUP_TOOLCHAIN = "1.89"; const STT_CRATES = [ "gateway-stt", "gateway-stt-engine", @@ -23,6 +25,13 @@ export function requireToolVersion(tool, output, expected) { } } +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}::`), @@ -214,10 +223,15 @@ function publicRootBudget(source, crateName) { return Number(matches[0][1]); } -function runCargo(root, args) { - const result = spawnSync("cargo", args, { +export function runCargo( + root, + args, + { spawn = spawnSync, env = process.env } = {}, +) { + const result = spawn("cargo", args, { cwd: root, encoding: "utf8", + env: { ...env, RUSTUP_TOOLCHAIN }, maxBuffer: 64 * 1024 * 1024, windowsHide: true, }); @@ -242,6 +256,7 @@ function checkNodeVersion() { function main() { checkNodeVersion(); const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + requireCargoVersion(runCargo(root, ["--version"])); requireToolVersion( "cargo-modules", runCargo(root, ["modules", "--version"]), diff --git a/tools/check-stt-architecture.test.mjs b/tools/check-stt-architecture.test.mjs index 1146f777..1ce1eac7 100644 --- a/tools/check-stt-architecture.test.mjs +++ b/tools/check-stt-architecture.test.mjs @@ -5,7 +5,9 @@ import { assertAcyclic, countEffectiveRootNames, parseCargoModulesDot, + requireCargoVersion, requireToolVersion, + runCargo, } from "./check-stt-architecture.mjs"; test("DOT parser collapses item edges to their owning modules", () => { @@ -94,3 +96,76 @@ test("tool version parser rejects an unpinned version", () => { /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"); +}); + +test("cargo-public-api child cannot inherit ambient Cargo 1.98", () => { + let child; + runCargo("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, ["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, "1.89"); +}); + +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' is not installed", + }; + }, + }), + /failed with status 1.*toolchain '1\.89' 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/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index b67cb93a..79a28c54 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -25,7 +25,7 @@ todos: status: pending - id: ci-architecture-toolchain content: Run pinned architecture tools under the repository Cargo version inside stable CI - status: pending + status: completed - id: ci-workshop-sidecars content: Stage target-named Gateway sidecars before Windows and Linux Workshop CI builds status: pending @@ -478,7 +478,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. From f10f7c966aba5dc51ee834e5b5a5cbc16e8e5dd1 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 08:49:46 -0700 Subject: [PATCH 21/86] Remove unused STT test imports Remove extension trait imports that the test module does not use. Plan: none --- crates/workshop-server/src/routes/stt.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/workshop-server/src/routes/stt.rs b/crates/workshop-server/src/routes/stt.rs index 388c3830..42825ab5 100644 --- a/crates/workshop-server/src/routes/stt.rs +++ b/crates/workshop-server/src/routes/stt.rs @@ -142,7 +142,6 @@ 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 _; From 32717a11edb3f797c337f4ac70d89f82b06150eb Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 08:39:05 -0700 Subject: [PATCH 22/86] Stage Gateway sidecars for Workshop CI Build featureless Gateway binaries before Workshop compilation so Tauri can resolve its required external binary. Stage each binary under the target-qualified name for Windows and Linux, then remove it even when later checks fail. - `TARGETS` centralizes the supported source and sidecar names for the Windows and Linux build targets. - `.github/workflows/ci.yml` builds each featureless Gateway, stages it before Workshop checks, and removes it with an unconditional cleanup step. - `stageGatewaySidecar` rejects unsupported targets, missing sources, non-file sources, and platform-name mismatches before it copies a binary. - `tools/stage-gateway-sidecar.test.mjs` pins both target mappings, rejection paths, byte-preserving staging, and repeatable removal. Design: new surface-growth @ tools/stage-gateway-sidecar.mjs::gatewayBinaryName deps: target boundary: pub Design: new pure-function @ tools/stage-gateway-sidecar.mjs::gatewayBinaryName deps: target boundary: pub Design: new surface-growth @ tools/stage-gateway-sidecar.mjs::gatewaySidecarName deps: target boundary: pub Design: new pure-function @ tools/stage-gateway-sidecar.mjs::gatewaySidecarName deps: target boundary: pub Design: new surface-growth @ tools/stage-gateway-sidecar.mjs::stageGatewaySidecar deps: root,source,target boundary: pub Design: new surface-growth @ tools/stage-gateway-sidecar.mjs::removeGatewaySidecar deps: root,target boundary: pub Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- .github/workflows/ci.yml | 20 ++++ tools/stage-gateway-sidecar.mjs | 135 ++++++++++++++++++++++ tools/stage-gateway-sidecar.test.mjs | 122 +++++++++++++++++++ vibe/2026-09-05-2-generic-realtime-stt.md | 4 +- 4 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 tools/stage-gateway-sidecar.mjs create mode 100644 tools/stage-gateway-sidecar.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3aef157b..a9af1bcb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,12 +124,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: | @@ -172,9 +182,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/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-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 79a28c54..5f1e96bb 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -28,7 +28,7 @@ todos: status: completed - id: ci-workshop-sidecars content: Stage target-named Gateway sidecars before Windows and Linux Workshop CI builds - status: pending + status: completed isProject: false --- @@ -488,7 +488,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. From c65961a97fbead9ccff33283c9b9d156320d2d9a Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 09:10:22 -0700 Subject: [PATCH 23/86] Bound Realtime session input ownership Own each uncommitted audio stream, immutable configuration snapshot, interim epoch, and cleanup task within one isolated session. Reject excess sessions or canceled-task retention immediately, preserve retryable state on overload, and run pure ownership paths under Miri. - `InputSnapshot` freezes the effective prompt and hypothesis option on the first successful append, while `UncommittedInput` owns audio conversion and take state until clear. - `SessionRegistry` shares synchronized admission state across handles and keeps retiring sessions counted until every aborted interim task joins. - `Session` receives its registration and optional engine at construction, owns bounded canceled-task joins, and rejects stale epochs before allocating event identifiers. - `test_fixtures` adds a feature-gated public session surface for deterministic integration coverage. - `realtime_session` pins exact session and canceled-join capacities, cancellation-safe retries, reset behavior, and immutable first-append state. - `realtime` remains private and is not wired to a socket route. Design: new value-object @ crates/gateway-stt/src/realtime/input.rs::InputSnapshot Design: new constructor-injection @ crates/gateway-stt/src/realtime/input.rs::UncommittedInput::new Design: new oversized-unit @ crates/gateway-stt/src/realtime/input.rs Design: new shared-mutable-state @ crates/gateway-stt/src/realtime/registry.rs::SessionRegistry Design: new oversized-unit @ crates/gateway-stt/src/realtime/registry.rs Design: new constructor-injection @ crates/gateway-stt/src/realtime/session.rs::Session::new Design: new oversized-unit @ crates/gateway-stt/src/realtime/session.rs Design: new surface-growth @ crates/gateway-stt/src/test_fixtures.rs::RealtimeSessionRegistryFixture boundary: pub Design: new surface-growth @ crates/gateway-stt/src/test_fixtures.rs::RealtimeInterimEpoch boundary: pub Design: new surface-growth @ crates/gateway-stt/src/test_fixtures.rs::RealtimeInputSnapshotFixture boundary: pub Design: new surface-growth @ crates/gateway-stt/src/test_fixtures.rs::RealtimeSessionFixture boundary: pub Design: new oversized-unit @ crates/gateway-stt/src/test_fixtures.rs Design: new clone-block @ crates/gateway-stt/tests/it/realtime_session.rs Design: new oversized-unit @ crates/gateway-stt/tests/it/realtime_session.rs Violates: A2 - credential ownership in crates/gateway-stt/src/realtime is not determinable from diff Pending: N5 - compounds Pending: N6 - compounds Pending: N24 - compounds Pending: N26 - compounds Pending: N30 - compounds Deferred: Realtime socket route integration remains unwired Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- .github/workflows/stt-miri.yml | 7 +- Cargo.lock | 1 + crates/gateway-stt/Cargo.toml | 5 +- crates/gateway-stt/module-ceilings.toml | 15 +- crates/gateway-stt/src/audio.rs | 18 +- crates/gateway-stt/src/lib.rs | 2 + crates/gateway-stt/src/realtime/input.rs | 160 +++++++ crates/gateway-stt/src/realtime/mod.rs | 8 + crates/gateway-stt/src/realtime/registry.rs | 180 ++++++++ crates/gateway-stt/src/realtime/session.rs | 408 ++++++++++++++++++ .../gateway-stt/src/realtime/wire/server.rs | 31 +- .../gateway-stt/src/realtime/wire/shared.rs | 9 +- crates/gateway-stt/src/test_fixtures.rs | 197 +++++++++ crates/gateway-stt/tests/it/main.rs | 7 + .../gateway-stt/tests/it/realtime_session.rs | 287 ++++++++++++ vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- vibe/archdoc-next.md | 11 +- 17 files changed, 1323 insertions(+), 25 deletions(-) create mode 100644 crates/gateway-stt/src/realtime/input.rs create mode 100644 crates/gateway-stt/src/realtime/registry.rs create mode 100644 crates/gateway-stt/src/realtime/session.rs create mode 100644 crates/gateway-stt/tests/it/realtime_session.rs diff --git a/.github/workflows/stt-miri.yml b/.github/workflows/stt-miri.yml index d083d5f0..3f7c6914 100644 --- a/.github/workflows/stt-miri.yml +++ b/.github/workflows/stt-miri.yml @@ -14,7 +14,7 @@ concurrency: cancel-in-progress: true jobs: - pure-worker-state: + pure-stt-state: runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -37,6 +37,11 @@ jobs: - name: Check pure STT worker ownership and queues run: cargo +nightly-2026-09-05 miri test -p gateway-stt-engine --features test-fixtures miri_ + # Session filters cover only registry, immutable input ownership, audio + # state, and epoch transitions. Socket and spawned-task tests stay native. + - name: Check pure STT 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 diff --git a/Cargo.lock b/Cargo.lock index 6e42db34..7a5b25e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2000,6 +2000,7 @@ dependencies = [ "futures-util", "gateway-config", "gateway-local", + "gateway-stt", "gateway-stt-backend-whisper", "gateway-stt-engine", "hound", diff --git a/crates/gateway-stt/Cargo.toml b/crates/gateway-stt/Cargo.toml index a2b7b95b..12f51f07 100644 --- a/crates/gateway-stt/Cargo.toml +++ b/crates/gateway-stt/Cargo.toml @@ -19,14 +19,17 @@ gateway-local.workspace = true shared-progress.workspace = true gateway-stt-backend-whisper.workspace = true gateway-stt-engine.workspace = true -workshop-server.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true tokio.workspace = true tracing.workspace = true +[target.'cfg(not(miri))'.dependencies] +workshop-server.workspace = true + [dev-dependencies] +gateway-stt = { path = ".", features = ["test-fixtures"] } sha2.workspace = true tempfile.workspace = true tokio-tungstenite.workspace = true diff --git a/crates/gateway-stt/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index 67baf0e3..433e46e9 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -22,17 +22,20 @@ destination = "independent committed-item finalization" [modules] "api.rs" = 625 -"audio.rs" = 380 -"lib.rs" = 28 -"realtime/mod.rs" = 2 +"audio.rs" = 390 +"lib.rs" = 30 +"realtime/mod.rs" = 10 +"realtime/input.rs" = 160 "realtime/query.rs" = 70 +"realtime/registry.rs" = 180 +"realtime/session.rs" = 409 "realtime/wire.rs" = 24 "realtime/wire/client.rs" = 363 -"realtime/wire/server.rs" = 367 -"realtime/wire/shared.rs" = 212 +"realtime/wire/server.rs" = 389 +"realtime/wire/shared.rs" = 218 "realtime/wire/tests.rs" = 278 "runtime.rs" = 459 "segment.rs" = 239 "stt.rs" = 733 "take.rs" = 677 -"test_fixtures.rs" = 73 +"test_fixtures.rs" = 271 diff --git a/crates/gateway-stt/src/audio.rs b/crates/gateway-stt/src/audio.rs index 9f12b06e..4bfe948f 100644 --- a/crates/gateway-stt/src/audio.rs +++ b/crates/gateway-stt/src/audio.rs @@ -114,6 +114,10 @@ impl AudioBuffer { *self = Self::default(); } + pub(super) fn take_resampled(&mut self) -> Vec { + std::mem::take(&mut self.resampler.output) + } + #[allow(clippy::cast_precision_loss)] pub(super) fn buffered_duration_seconds(&self) -> f64 { self.input_samples as f64 / INPUT_SAMPLE_RATE as f64 @@ -149,17 +153,18 @@ struct Resampler24To16 { 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.output.push(sample); + self.emit(sample); self.next_output_twice += 3; } else if self.next_output_twice < input_twice { let previous = self.previous.unwrap_or(sample); - self.output.push(previous.midpoint(sample)); + self.emit(previous.midpoint(sample)); self.next_output_twice += 3; } self.previous = Some(sample); @@ -170,14 +175,19 @@ impl Resampler24To16 { if self.next_output_twice < self.input_index * 2 && let Some(previous) = self.previous { - self.output.push(previous); + self.emit(previous); self.next_output_twice += 3; } debug_assert_eq!( - self.output.len(), + 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)] diff --git a/crates/gateway-stt/src/lib.rs b/crates/gateway-stt/src/lib.rs index bbae25d5..224f9edb 100644 --- a/crates/gateway-stt/src/lib.rs +++ b/crates/gateway-stt/src/lib.rs @@ -14,6 +14,7 @@ mod audio; mod realtime; mod runtime; mod segment; +#[cfg(not(miri))] mod stt; mod take; #[cfg(all(test, not(feature = "test-fixtures")))] @@ -24,4 +25,5 @@ pub mod test_fixtures; pub use api::{MAX_AUDIO_BYTES, TranscriptionError, transcribe}; pub use runtime::{SttRuntime, SttRuntimeError, SttState}; pub use segment::Segmenter; +#[cfg(not(miri))] pub use stt::{gateway_routes, routes as stt_routes}; diff --git a/crates/gateway-stt/src/realtime/input.rs b/crates/gateway-stt/src/realtime/input.rs new file mode 100644 index 00000000..c61957ad --- /dev/null +++ b/crates/gateway-stt/src/realtime/input.rs @@ -0,0 +1,160 @@ +use std::sync::Arc; + +use gateway_stt_engine::SttEngine; + +use crate::audio::{AudioBuffer, AudioError}; +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, + } + } + + pub(crate) fn prompt(&self) -> &str { + &self.prompt + } + + pub(crate) const fn format(&self) -> &str { + self.format + } + + pub(crate) const fn rate(&self) -> u32 { + self.rate + } + + 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, +} + +impl UncommittedInput { + 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()); + Self { + item_id, + snapshot, + audio, + take, + } + } + + pub(crate) fn append_base64(&mut self, payload: &str) -> Result<(), AudioError> { + self.audio.append_base64(payload)?; + self.take.append(&self.audio.take_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 + } + + pub(crate) fn buffered_duration_seconds(&self) -> f64 { + self.audio.buffered_duration_seconds() + } +} + +#[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/mod.rs b/crates/gateway-stt/src/realtime/mod.rs index 0b94c461..27637eeb 100644 --- a/crates/gateway-stt/src/realtime/mod.rs +++ b/crates/gateway-stt/src/realtime/mod.rs @@ -1,2 +1,10 @@ +mod input; mod query; +mod registry; +mod session; mod wire; + +#[cfg(feature = "test-fixtures")] +pub(crate) use registry::SessionRegistry; +#[cfg(feature = "test-fixtures")] +pub(crate) use session::{InterimEpoch, Session}; diff --git a/crates/gateway-stt/src/realtime/registry.rs b/crates/gateway-stt/src/realtime/registry.rs new file mode 100644 index 00000000..76cff73b --- /dev/null +++ b/crates/gateway-stt/src/realtime/registry.rs @@ -0,0 +1,180 @@ +use std::fmt; +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex, PoisonError}; +use std::task::{Context, Poll}; + +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, + retiring: Vec, +} + +impl RegistryState { + fn reap_retired(&mut self) { + let before = self.retiring.len(); + self.retiring.retain_mut(|session| !session.joined()); + self.active = self + .active + .saturating_sub(before.saturating_sub(self.retiring.len())); + } +} + +impl fmt::Debug for RegistryState { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RegistryState") + .field("active", &self.active) + .field("retiring", &self.retiring.len()) + .finish() + } +} + +trait RetiredTask: Send { + fn poll_join(&mut self) -> Poll<()>; +} + +impl RetiredTask for JoinHandle +where + T: Send + 'static, +{ + fn poll_join(&mut self) -> Poll<()> { + let waker = futures_util::task::noop_waker_ref(); + let mut context = Context::from_waker(waker); + Pin::new(self).poll(&mut context).map(|_result| ()) + } +} + +struct RetiringSession { + tasks: Vec>, +} + +impl RetiringSession { + fn joined(&mut self) -> bool { + let mut index = 0; + while index < self.tasks.len() { + if self.tasks[index].poll_join().is_ready() { + drop(self.tasks.remove(index)); + } else { + index += 1; + } + } + self.tasks.is_empty() + } +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct SessionRegistry { + state: Arc>, +} + +impl SessionRegistry { + pub(crate) fn register(&self) -> Result { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + state.reap_retired(); + if state.active == MAX_ACTIVE_REALTIME_SESSIONS { + return Err(RegisterError::AtCapacity); + } + state.active += 1; + Ok(SessionRegistration { + state: Some(Arc::clone(&self.state)), + }) + } + + pub(crate) fn active(&self) -> usize { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + state.reap_retired(); + state.active + } +} + +#[derive(Debug)] +pub(crate) struct SessionRegistration { + state: Option>>, +} + +impl SessionRegistration { + pub(crate) fn retire(&mut self, mut tasks: Vec>) + where + T: Send + 'static, + { + for task in &tasks { + task.abort(); + } + if tasks.is_empty() { + return; + } + let Some(state) = self.state.take() else { + return; + }; + state + .lock() + .unwrap_or_else(PoisonError::into_inner) + .retiring + .push(RetiringSession { + tasks: tasks + .drain(..) + .map(|task| Box::new(task) as Box) + .collect(), + }); + // The registry keeps this admission occupied until reap_retired + // polls every canceled task join to completion. + drop(state); + } +} + +impl Drop for SessionRegistration { + fn drop(&mut self) { + let Some(state) = self.state.take() else { + return; + }; + let mut state = state.lock().unwrap_or_else(PoisonError::into_inner); + debug_assert!(state.active > 0); + state.active = state.active.saturating_sub(1); + } +} + +#[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/session.rs b/crates/gateway-stt/src/realtime/session.rs new file mode 100644 index 00000000..eede8bd8 --- /dev/null +++ b/crates/gateway-stt/src/realtime/session.rs @@ -0,0 +1,408 @@ +use std::future::Future; +use std::sync::Arc; + +use gateway_stt_engine::SttEngine; +use tokio::task::JoinHandle; + +use super::input::{InputSnapshot, UncommittedInput}; +use super::registry::SessionRegistration; +use super::wire::{ClientError, EffectiveSession, IdGenerator, ServerEvent}; +use crate::audio::AudioError; + +pub(crate) const SESSION_CANCEL_JOIN_CAPACITY: usize = 8; +type InterimTask = JoinHandle<(InterimEpoch, String)>; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct InterimEpoch(u64); + +#[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, +} + +#[derive(Debug)] +pub(crate) struct Session { + registration: Option, + engine: Option>, + ids: IdGenerator, + effective: EffectiveSession, + input: Option, + current_epoch: Option, + next_epoch: u64, + interim_task: Option, + canceled_tasks: Vec, + canceled_task_failed: bool, +} + +impl Session { + pub(crate) fn new(registration: SessionRegistration, engine: Option>) -> Self { + let ids = IdGenerator::default(); + let effective = EffectiveSession::new(ids.session()); + Self { + registration: Some(registration), + engine, + ids, + effective, + input: None, + current_epoch: None, + next_epoch: 1, + interim_task: None, + canceled_tasks: Vec::with_capacity(SESSION_CANCEL_JOIN_CAPACITY), + canceled_task_failed: false, + } + } + + 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 { + 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.as_ref().map(Arc::clone), + payload, + )?; + self.input = Some(input); + Ok(()) + } + + 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; + 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) + } + + 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 { (epoch, task.await) })); + Ok(epoch) + } + + 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; + let (epoch, transcript) = result.map_err(|_| SessionError::CanceledTaskFailed)?; + Ok(self.accept_interim(epoch, transcript)) + } + + pub(crate) const fn canceled_join_count(&self) -> usize { + self.canceled_tasks.len() + } + + #[cfg(any(test, feature = "test-fixtures"))] + pub(crate) fn allocated_event_count(&self) -> u64 { + self.ids.event_count() + } + + 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); + if result.is_err_and(|error| !error.is_cancelled()) { + self.canceled_task_failed = true; + } + } + 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 tasks = Vec::with_capacity(self.canceled_tasks.len() + 1); + if let Some(task) = self.interim_task.take() { + tasks.push(task); + } + tasks.append(&mut self.canceled_tasks); + if let Some(mut registration) = self.registration.take() { + registration.retire(tasks); + } + } +} + +#[cfg(test)] +mod tests { + use std::future::pending; + + use base64::Engine as _; + + use super::{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() + ); + } + + #[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/wire/server.rs b/crates/gateway-stt/src/realtime/wire/server.rs index d9ff1aae..f7304a33 100644 --- a/crates/gateway-stt/src/realtime/wire/server.rs +++ b/crates/gateway-stt/src/realtime/wire/server.rs @@ -9,7 +9,7 @@ use super::shared::{ #[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub(in crate::realtime) struct EffectiveSession { +pub(crate) struct EffectiveSession { id: String, object: String, r#type: String, @@ -94,6 +94,14 @@ impl EffectiveSession { 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() + } + fn validate(&self) -> Result<(), String> { if self.id.is_empty() || self.object != SESSION_OBJECT @@ -114,7 +122,7 @@ impl EffectiveSession { #[derive(Debug, Serialize, Deserialize)] #[serde(tag = "type", deny_unknown_fields)] -pub(in crate::realtime) enum ServerEvent { +pub(crate) enum ServerEvent { #[serde(rename = "session.created")] SessionCreated { event_id: String, @@ -182,7 +190,7 @@ pub(in crate::realtime) enum ServerEvent { #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub(in crate::realtime) struct ConversationItem { +pub(crate) struct ConversationItem { id: String, r#type: String, status: String, @@ -200,14 +208,14 @@ struct InputAudioContent { #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub(in crate::realtime) struct DurationUsage { +pub(crate) struct DurationUsage { r#type: String, seconds: f64, } #[derive(Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub(in crate::realtime) struct WireError { +pub(crate) struct WireError { r#type: String, code: String, message: String, @@ -218,6 +226,19 @@ pub(in crate::realtime) struct WireError { } 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, + } + } + 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()?; diff --git a/crates/gateway-stt/src/realtime/wire/shared.rs b/crates/gateway-stt/src/realtime/wire/shared.rs index ecd5cb47..7726dcec 100644 --- a/crates/gateway-stt/src/realtime/wire/shared.rs +++ b/crates/gateway-stt/src/realtime/wire/shared.rs @@ -42,7 +42,7 @@ pub(super) enum Correlation { } #[derive(Debug, Clone, Eq, PartialEq)] -pub(in crate::realtime) struct ClientError { +pub(crate) struct ClientError { code: &'static str, message: String, param: Option, @@ -86,7 +86,7 @@ impl ClientError { } #[derive(Debug, Clone, Eq, PartialEq)] -pub(in crate::realtime) enum RequiredNullable { +pub(crate) enum RequiredNullable { Null, Value(T), } @@ -205,6 +205,11 @@ impl IdGenerator { 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/test_fixtures.rs b/crates/gateway-stt/src/test_fixtures.rs index a6395682..b0e9f04d 100644 --- a/crates/gateway-stt/src/test_fixtures.rs +++ b/crates/gateway-stt/src/test_fixtures.rs @@ -11,7 +11,11 @@ pub use gateway_stt_engine::test_fixtures::{ScriptedDecoder, ScriptedModelFactor #[cfg(feature = "test-fixtures")] use crate::SttRuntime; #[cfg(feature = "test-fixtures")] +use crate::realtime::{InterimEpoch, Session, SessionRegistry}; +#[cfg(feature = "test-fixtures")] use gateway_stt_engine::{EnginePolicy, SttEngine, TranscribeError}; +#[cfg(feature = "test-fixtures")] +use std::future::Future; /// Builds a speech runtime around deterministic scripted workers. /// @@ -35,6 +39,199 @@ pub fn scripted_runtime( )) } +/// 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), + }) + } + + /// Returns active and still-retiring session ownership. + #[must_use] + pub fn active(&self) -> usize { + self.inner.active() + } +} + +/// An opaque interim epoch used by the Realtime session fixture. +#[cfg(feature = "test-fixtures")] +#[derive(Clone, Copy, Debug)] +pub struct RealtimeInterimEpoch(InterimEpoch); + +/// 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, +} + +#[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)) + } + + /// Begins an interim epoch without spawning work. + /// + /// # Errors + /// Returns the session ownership or epoch error. + pub fn begin_interim(&mut self) -> Result { + self.session + .begin_interim() + .map(RealtimeInterimEpoch) + .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 + where + F: Future + Send + 'static, + { + self.session + .spawn_interim(task) + .map(RealtimeInterimEpoch) + .map_err(|error| error.to_string()) + } + + /// Accepts a result and allocates its event ID only after epoch validation. + /// + /// # Errors + /// Returns a serialization error if the accepted server event cannot serialize. + pub fn accept_interim( + &mut self, + epoch: RealtimeInterimEpoch, + transcript: String, + ) -> Result, serde_json::Error> { + self.session + .accept_interim(epoch.0, transcript) + .map(serde_json::to_value) + .transpose() + } + + /// 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(test)] pub(crate) fn require_model() -> PathBuf { require_fixture("PROMPTFORGE_WHISPER_MODEL", "ggml-tiny.en.bin") diff --git a/crates/gateway-stt/tests/it/main.rs b/crates/gateway-stt/tests/it/main.rs index 2524f263..6d663e33 100644 --- a/crates/gateway-stt/tests/it/main.rs +++ b/crates/gateway-stt/tests/it/main.rs @@ -1,9 +1,16 @@ //! STT HTTP and WebSocket integration tests. +#[cfg(not(miri))] #[path = "../common/mod.rs"] mod common; +#[cfg(not(miri))] mod architecture; +#[cfg(not(miri))] mod batch; +#[cfg(not(miri))] mod legacy_stream; +#[cfg(not(miri))] mod realtime_fixtures; +#[cfg(not(miri))] +mod realtime_session; 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..b07412f2 --- /dev/null +++ b/crates/gateway-stt/tests/it/realtime_session.rs @@ -0,0 +1,287 @@ +use std::future::{Future, pending}; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::task::{Context, Poll}; + +use base64::Engine as _; +use futures_util::FutureExt as _; +use gateway_stt::test_fixtures::{RealtimeSessionFixture, RealtimeSessionRegistryFixture}; + +const SESSION_CAPACITY: usize = 8; +const CANCEL_JOIN_CAPACITY: usize = 8; + +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() +} + +#[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 BlockingDrop { + dropping: Arc, + release: Arc, +} + +impl Future for BlockingDrop { + type Output = String; + + fn poll(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll { + Poll::Pending + } +} + +impl Drop for BlockingDrop { + fn drop(&mut self) { + self.dropping.store(true, Ordering::Release); + while !self.release.load(Ordering::Acquire) { + std::thread::yield_now(); + } + } +} + +async fn wait_until(predicate: impl Fn() -> bool) { + for _ in 0..1_000 { + if predicate() { + return; + } + tokio::task::yield_now().await; + } + panic!("condition did not become true within the bounded yield budget"); +} + +#[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)] +async fn dropping_session_retains_admission_until_interim_cleanup_joins() { + let registry = RealtimeSessionRegistryFixture::default(); + let mut session = registry.register().expect("session registers"); + session + .append_base64(&encoded(&[0, 0])) + .expect("input appends"); + let dropping = Arc::new(AtomicBool::new(false)); + let release = Arc::new(AtomicBool::new(false)); + session + .spawn_interim(BlockingDrop { + dropping: Arc::clone(&dropping), + release: Arc::clone(&release), + }) + .expect("interim starts"); + + drop(session); + wait_until(|| dropping.load(Ordering::Acquire)).await; + assert_eq!(registry.active(), 1, "retiring work keeps admission owned"); + release.store(true, Ordering::Release); + wait_until(|| registry.active() == 0).await; +} + +#[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)] +async fn canceling_join_keeps_capacity_owned_until_retry_completes() { + let mut session = session(); + session + .append_base64(&encoded(&[0, 0])) + .expect("input appends"); + let dropping = Arc::new(AtomicBool::new(false)); + let release = Arc::new(AtomicBool::new(false)); + session + .spawn_interim(BlockingDrop { + dropping: Arc::clone(&dropping), + release: Arc::clone(&release), + }) + .expect("interim starts"); + session.clear().expect("interim retires"); + wait_until(|| dropping.load(Ordering::Acquire)).await; + + 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 stale = session.begin_interim().expect("epoch begins"); + let current_event = session + .accept_interim(stale, "current".to_owned()) + .expect("event serializes") + .expect("current epoch is accepted"); + assert_eq!(current_event["delta"], "current"); + assert_eq!(session.allocated_event_count(), 1); + + session.clear().expect("input clears"); + assert!( + session + .accept_interim(stale, "stale".to_owned()) + .expect("rejection does not serialize") + .is_none() + ); + assert_eq!( + session.allocated_event_count(), + 1, + "stale completion consumes no event ID" + ); +} diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 5f1e96bb..52d732a2 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -500,7 +500,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index c5a451a3..09192942 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -130,8 +130,8 @@ N1 | observation | Violates A2 @ crates/gateway-stt/tests/fixtures/realtime: not N2 | observation | Violates A96 @ crates/workshop-server/ui/test/realtime-wire-fixtures.mjs: not determinable from diff | Freeze the realtime transcription wire contract 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 -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 +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 +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 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 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 @@ -149,13 +149,14 @@ N20 | observation | surface-growth @ crates/gateway-stt-engine/src/lib.rs::test_ 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 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 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 +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 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 -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 +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 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 +N30 | observation | Violates A2 @ crates/gateway-stt/src/realtime: not determinable from diff | Define the private Realtime wire; Bound Realtime session input ownership 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 From 1fc3c2076ff2a2f0b07634dc340b5e8e172aa407 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 09:56:13 -0700 Subject: [PATCH 24/86] Finalize realtime items independently Give each committed transcription item its own immutable input snapshot, take state, finalization task, durable lineage, and terminal outcome. Reserve bounded result, hypothesis, terminal, and final-segment capacity before detaching input so overload stays retryable and items can finish out of order. Extend deterministic and interpreter coverage for exact capacities, isolation, cancellation, failure, and cleanup. Record native runner remediation as deferred work, synchronize later migration deadlines, and preserve the promoted architecture comparison reports. - `CommittedItem` owns one sealed input, one take, one finalization task, and one terminal transition. `Session::commit` validates and reserves capacity before it invalidates the interim epoch or detaches input. - `FinalPipeline` uses bounded message passing for accurate segments and completion. `SessionRegistry` keeps admission occupied until canceled interim and finalization tasks finish joining. - `ResultMailbox` bounds ordinary results at 16 while reserving one replaceable hypothesis and one terminal slot per item. `MAX_COMMITTED_ITEMS_PER_SESSION` and `FINAL_SEGMENT_CAPACITY` enforce four-item and four-segment limits. - `.github/workflows/stt-miri.yml` expands interpreter coverage to committed-item ownership and queue bounds. `module-ceilings.toml` removes the completed take migration and shifts later migration targets. - `vibe/stt-field-comparison-and-adoption.md` and `vibe/agent-runtime-field-comparison-and-adoption.md` preserve the promoted field reports. - `ci-native-rustup` records the self-hosted runner preflight as pending; the native job remains unchanged. Design: new shared-mutable-state @ crates/gateway-stt/src/realtime/item.rs::CommittedItem Design: new oversized-unit @ crates/gateway-stt/src/realtime/item.rs Design: extends oversized-unit @ crates/gateway-stt/src/realtime/input.rs Design: extends shared-mutable-state @ crates/gateway-stt/src/realtime/registry.rs::SessionRegistry Design: extends oversized-unit @ crates/gateway-stt/src/realtime/registry.rs Design: new oversized-unit @ crates/gateway-stt/src/realtime/result_mailbox.rs Design: extends oversized-unit @ crates/gateway-stt/src/realtime/session.rs Design: new oversized-unit @ crates/gateway-stt/src/realtime/session/items.rs Design: new oversized-unit @ crates/gateway-stt/src/realtime/session/state.rs Design: replaces message-passing @ crates/gateway-stt/src/take/finalization.rs::FinalPipeline was: crates/gateway-stt/src/take.rs::FinalPipeline Design: new shared-mutable-state @ crates/gateway-stt/src/take/finalization.rs::FinalPipeline Design: replaces shared-mutable-state @ crates/gateway-stt/src/take/state.rs::TakeState was: crates/gateway-stt/src/take.rs::TakeState Design: new oversized-unit @ crates/gateway-stt/src/take/agreement.rs Design: new oversized-unit @ crates/gateway-stt/src/take/finalization.rs Design: new oversized-unit @ crates/gateway-stt/src/take/state.rs Design: new surface-growth @ crates/gateway-stt/src/test_fixtures.rs::RealtimeCommitFixture boundary: pub Design: extends surface-growth @ crates/gateway-stt/src/test_fixtures.rs::RealtimeSessionFixture boundary: pub Design: extends oversized-unit @ crates/gateway-stt/src/test_fixtures.rs Design: extends clone-block @ crates/gateway-stt/tests/it/realtime_session.rs Design: extends oversized-unit @ crates/gateway-stt/tests/it/realtime_session.rs Violates: A2 - credential ownership in crates/gateway-stt/src/realtime is not determinable from diff Pending: N6 - compounds Pending: N24 - compounds Pending: N30 - compounds Pending: N34 - compounds Deferred: self-hosted native runner Rust preflight remains unimplemented Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- .github/workflows/stt-miri.yml | 7 +- crates/gateway-stt/module-ceilings.toml | 32 +- crates/gateway-stt/src/audio.rs | 26 +- crates/gateway-stt/src/realtime/input.rs | 44 +- crates/gateway-stt/src/realtime/item.rs | 157 ++++++ crates/gateway-stt/src/realtime/mod.rs | 6 + crates/gateway-stt/src/realtime/registry.rs | 30 +- .../src/realtime/result_mailbox.rs | 207 ++++++++ crates/gateway-stt/src/realtime/session.rs | 134 +++-- .../gateway-stt/src/realtime/session/items.rs | 134 +++++ .../gateway-stt/src/realtime/session/state.rs | 83 +++ crates/gateway-stt/src/take.rs | 380 +++----------- crates/gateway-stt/src/take/agreement.rs | 116 +++++ crates/gateway-stt/src/take/finalization.rs | 175 +++++++ crates/gateway-stt/src/take/state.rs | 82 +++ crates/gateway-stt/src/take/text.rs | 9 + crates/gateway-stt/src/test_fixtures.rs | 201 +++++++- crates/gateway-stt/tests/it/architecture.rs | 17 +- .../gateway-stt/tests/it/realtime_session.rs | 480 +++++++++++++++++- vibe/2026-09-05-2-generic-realtime-stt.md | 77 +-- ...t-runtime-field-comparison-and-adoption.md | 137 +++++ vibe/archdoc-next.md | 10 +- vibe/stt-field-comparison-and-adoption.md | 126 +++++ 23 files changed, 2195 insertions(+), 475 deletions(-) create mode 100644 crates/gateway-stt/src/realtime/item.rs create mode 100644 crates/gateway-stt/src/realtime/result_mailbox.rs create mode 100644 crates/gateway-stt/src/realtime/session/items.rs create mode 100644 crates/gateway-stt/src/realtime/session/state.rs create mode 100644 crates/gateway-stt/src/take/agreement.rs create mode 100644 crates/gateway-stt/src/take/finalization.rs create mode 100644 crates/gateway-stt/src/take/state.rs create mode 100644 crates/gateway-stt/src/take/text.rs create mode 100644 vibe/agent-runtime-field-comparison-and-adoption.md create mode 100644 vibe/stt-field-comparison-and-adoption.md diff --git a/.github/workflows/stt-miri.yml b/.github/workflows/stt-miri.yml index 3f7c6914..3a2b627e 100644 --- a/.github/workflows/stt-miri.yml +++ b/.github/workflows/stt-miri.yml @@ -37,9 +37,10 @@ jobs: - name: Check pure STT worker ownership and queues run: cargo +nightly-2026-09-05 miri test -p gateway-stt-engine --features test-fixtures miri_ - # Session filters cover only registry, immutable input ownership, audio - # state, and epoch transitions. Socket and spawned-task tests stay native. - - name: Check pure STT session ownership + # Session filters cover registry, immutable input and committed-item + # ownership, result and final-segment bounds, audio state, and epochs. + # Socket and spawned-task tests stay native. + - name: Check pure STT session and item ownership run: cargo +nightly-2026-09-05 miri test -p gateway-stt --features test-fixtures miri_ native-whisper: diff --git a/crates/gateway-stt/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index 433e46e9..3d705584 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -5,30 +5,30 @@ public_root_budget = 9 [migration_targets."api.rs"] -target_step = "Step 17" +target_step = "Step 18" destination = "batch.rs" [migration_targets."runtime.rs"] -target_step = "Step 17" +target_step = "Step 18" destination = "service.rs, artifacts.rs, generation.rs, status.rs, and model.rs" [migration_targets."stt.rs"] -target_step = "Step 28" +target_step = "Step 29" destination = "removal after the Realtime route and Workshop relay replace the legacy socket" -[migration_targets."take.rs"] -target_step = "Step 16" -destination = "independent committed-item finalization" - [modules] "api.rs" = 625 -"audio.rs" = 390 +"audio.rs" = 397 "lib.rs" = 30 -"realtime/mod.rs" = 10 -"realtime/input.rs" = 160 +"realtime/mod.rs" = 16 +"realtime/input.rs" = 198 +"realtime/item.rs" = 157 "realtime/query.rs" = 70 -"realtime/registry.rs" = 180 -"realtime/session.rs" = 409 +"realtime/registry.rs" = 200 +"realtime/result_mailbox.rs" = 207 +"realtime/session.rs" = 438 +"realtime/session/items.rs" = 134 +"realtime/session/state.rs" = 83 "realtime/wire.rs" = 24 "realtime/wire/client.rs" = 363 "realtime/wire/server.rs" = 389 @@ -37,5 +37,9 @@ destination = "independent committed-item finalization" "runtime.rs" = 459 "segment.rs" = 239 "stt.rs" = 733 -"take.rs" = 677 -"test_fixtures.rs" = 271 +"take.rs" = 420 +"take/agreement.rs" = 116 +"take/finalization.rs" = 175 +"take/state.rs" = 82 +"take/text.rs" = 10 +"test_fixtures.rs" = 470 diff --git a/crates/gateway-stt/src/audio.rs b/crates/gateway-stt/src/audio.rs index 4bfe948f..abf5b283 100644 --- a/crates/gateway-stt/src/audio.rs +++ b/crates/gateway-stt/src/audio.rs @@ -91,6 +91,22 @@ impl AudioBuffer { } 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); } @@ -99,15 +115,7 @@ impl AudioBuffer { minimum_ms: MIN_COMMIT_MILLISECONDS, }); } - - self.resampler.flush(); - let samples = std::mem::take(&mut self.resampler.output); - let input_samples = self.input_samples; - self.clear(); - Ok(CommittedAudio { - samples, - input_samples, - }) + Ok(()) } pub(super) fn clear(&mut self) { diff --git a/crates/gateway-stt/src/realtime/input.rs b/crates/gateway-stt/src/realtime/input.rs index c61957ad..8b3d16c0 100644 --- a/crates/gateway-stt/src/realtime/input.rs +++ b/crates/gateway-stt/src/realtime/input.rs @@ -58,6 +58,14 @@ pub(crate) struct UncommittedInput { take: Take, } +#[derive(Debug)] +pub(crate) struct SealedInput { + pub(crate) item_id: String, + pub(crate) snapshot: InputSnapshot, + pub(crate) take: Take, + pub(crate) duration_seconds: f64, +} + impl UncommittedInput { pub(crate) fn new( item_id: String, @@ -91,17 +99,19 @@ impl UncommittedInput { }; let take = Take::new(guidance, engine); take.append(&audio.take_resampled()); - Self { + 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.take.append(&self.audio.take_resampled()); + self.submit_resampled(); Ok(()) } @@ -120,6 +130,34 @@ impl UncommittedInput { 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, + 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)] diff --git a/crates/gateway-stt/src/realtime/item.rs b/crates/gateway-stt/src/realtime/item.rs new file mode 100644 index 00000000..e55f17e3 --- /dev/null +++ b/crates/gateway-stt/src/realtime/item.rs @@ -0,0 +1,157 @@ +use std::sync::Arc; + +use tokio::task::JoinHandle; + +use super::input::{InputSnapshot, SealedInput}; +use super::result_mailbox::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, + snapshot: InputSnapshot, + 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, + 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 + } + + pub(crate) const fn snapshot(&self) -> &InputSnapshot { + &self.snapshot + } + + pub(crate) fn take(&self) -> &Take { + &self.take + } + + pub(crate) const fn is_finalizing(&self) -> bool { + self.finalization.is_some() + } + + 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(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() + } + + 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, message: String) -> Option { + if std::mem::replace(&mut self.terminal, true) { + return None; + } + Some(ItemResult::Failed { + item_id: self.id.clone(), + message, + }) + } +} + +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 index 27637eeb..8c43b694 100644 --- a/crates/gateway-stt/src/realtime/mod.rs +++ b/crates/gateway-stt/src/realtime/mod.rs @@ -1,10 +1,16 @@ mod input; +mod item; mod query; mod registry; +mod result_mailbox; mod session; mod wire; +#[cfg(feature = "test-fixtures")] +pub(crate) use item::CommitReceipt; #[cfg(feature = "test-fixtures")] pub(crate) use registry::SessionRegistry; #[cfg(feature = "test-fixtures")] +pub(crate) use result_mailbox::ItemResult; +#[cfg(feature = "test-fixtures")] pub(crate) use session::{InterimEpoch, Session}; diff --git a/crates/gateway-stt/src/realtime/registry.rs b/crates/gateway-stt/src/realtime/registry.rs index 76cff73b..706a0c00 100644 --- a/crates/gateway-stt/src/realtime/registry.rs +++ b/crates/gateway-stt/src/realtime/registry.rs @@ -96,6 +96,14 @@ impl SessionRegistry { state.reap_retired(); state.active } + + #[cfg(feature = "test-fixtures")] + pub(crate) fn owned_without_reaping(&self) -> usize { + self.state + .lock() + .unwrap_or_else(PoisonError::into_inner) + .active + } } #[derive(Debug)] @@ -104,14 +112,21 @@ pub(crate) struct SessionRegistration { } impl SessionRegistration { - pub(crate) fn retire(&mut self, mut tasks: Vec>) - where + pub(crate) fn retire( + &mut self, + mut interim_tasks: Vec>, + mut finalization_tasks: Vec>, + ) where T: Send + 'static, + U: Send + 'static, { - for task in &tasks { + for task in &interim_tasks { + task.abort(); + } + for task in &finalization_tasks { task.abort(); } - if tasks.is_empty() { + if interim_tasks.is_empty() && finalization_tasks.is_empty() { return; } let Some(state) = self.state.take() else { @@ -122,9 +137,14 @@ impl SessionRegistration { .unwrap_or_else(PoisonError::into_inner) .retiring .push(RetiringSession { - tasks: tasks + tasks: interim_tasks .drain(..) .map(|task| Box::new(task) as Box) + .chain( + finalization_tasks + .drain(..) + .map(|task| Box::new(task) as Box), + ) .collect(), }); // The registry keeps this admission occupied until reap_retired 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..7764f634 --- /dev/null +++ b/crates/gateway-stt/src/realtime/result_mailbox.rs @@ -0,0 +1,207 @@ +use std::collections::{HashMap, VecDeque}; + +pub(crate) const SESSION_RESULT_CAPACITY: usize = 16; + +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum ItemResult { + Delta { + item_id: String, + transcript: String, + }, + Hypothesis { + item_id: String, + revision: u64, + transcript: String, + }, + Completed { + item_id: String, + transcript: String, + seconds: f64, + }, + Failed { + item_id: String, + message: String, + }, +} + +impl ItemResult { + pub(crate) fn item_id(&self) -> &str { + match self { + Self::Delta { item_id, .. } + | Self::Hypothesis { 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 { + hypothesis: Option, + terminal: Option, +} + +#[derive(Debug, Eq, PartialEq, thiserror::Error)] +pub(crate) enum MailboxError { + #[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 { + results: VecDeque, + slots: HashMap, + 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"); + } + + 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(()) + } + + 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 { + let mut drained = self.results.drain(..).collect::>(); + 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/session.rs b/crates/gateway-stt/src/realtime/session.rs index eede8bd8..e030c479 100644 --- a/crates/gateway-stt/src/realtime/session.rs +++ b/crates/gateway-stt/src/realtime/session.rs @@ -2,63 +2,25 @@ use std::future::Future; use std::sync::Arc; use gateway_stt_engine::SttEngine; -use tokio::task::JoinHandle; use super::input::{InputSnapshot, UncommittedInput}; +use super::item::CommittedItem; use super::registry::SessionRegistration; use super::wire::{ClientError, EffectiveSession, IdGenerator, ServerEvent}; -use crate::audio::AudioError; - -pub(crate) const SESSION_CANCEL_JOIN_CAPACITY: usize = 8; -type InterimTask = JoinHandle<(InterimEpoch, String)>; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) struct InterimEpoch(u64); - -#[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, -} -#[derive(Debug)] -pub(crate) struct Session { - registration: Option, - engine: Option>, - ids: IdGenerator, - effective: EffectiveSession, - input: Option, - current_epoch: Option, - next_epoch: u64, - interim_task: Option, - canceled_tasks: Vec, - canceled_task_failed: bool, -} +mod items; +mod state; + +#[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 { - registration: Some(registration), - engine, - ids, - effective, - input: None, - current_epoch: None, - next_epoch: 1, - interim_task: None, - canceled_tasks: Vec::with_capacity(SESSION_CANCEL_JOIN_CAPACITY), - canceled_task_failed: false, - } + Self::empty(registration, engine, ids, effective) } pub(crate) fn update_text(&mut self, text: &str) -> Result<(), ClientError> { @@ -67,6 +29,9 @@ impl Session { 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); } @@ -165,6 +130,26 @@ impl Session { 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() @@ -198,13 +183,18 @@ impl Session { impl Drop for Session { fn drop(&mut self) { - let mut tasks = Vec::with_capacity(self.canceled_tasks.len() + 1); + let mut interim_tasks = Vec::with_capacity(self.canceled_tasks.len() + 1); if let Some(task) = self.interim_task.take() { - tasks.push(task); + interim_tasks.push(task); } - tasks.append(&mut self.canceled_tasks); + 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(tasks); + registration.retire(interim_tasks, finalization_tasks); } } } @@ -215,7 +205,9 @@ mod tests { use base64::Engine as _; - use super::{SESSION_CANCEL_JOIN_CAPACITY, Session, SessionError}; + use super::{ + MAX_COMMITTED_ITEMS_PER_SESSION, SESSION_CANCEL_JOIN_CAPACITY, Session, SessionError, + }; use crate::realtime::registry::SessionRegistry; fn encoded(samples: &[i16]) -> String { @@ -346,6 +338,44 @@ mod tests { ); } + #[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); 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..aefa3052 --- /dev/null +++ b/crates/gateway-stt/src/realtime/session/items.rs @@ -0,0 +1,134 @@ +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::{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); + } + 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(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) + } + + pub(crate) fn committed_count(&self) -> usize { + self.committed.len() + } + + pub(crate) fn finalizing_count(&self) -> usize { + self.committed + .values() + .filter(|item| item.is_finalizing()) + .count() + } + + 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())) + } + + pub(crate) fn push_delta( + &mut self, + item_id: &str, + transcript: String, + ) -> Result<(), SessionError> { + self.results.push_delta(item_id, transcript)?; + Ok(()) + } + + 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(()) + } + + 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(()) + } + + 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(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/state.rs b/crates/gateway-stt/src/realtime/session/state.rs new file mode 100644 index 00000000..36553b43 --- /dev/null +++ b/crates/gateway-stt/src/realtime/session/state.rs @@ -0,0 +1,83 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use gateway_stt_engine::SttEngine; +use tokio::task::JoinHandle; + +use crate::audio::AudioError; +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}; + +pub(super) const SESSION_CANCEL_JOIN_CAPACITY: usize = 8; +pub(super) const MAX_COMMITTED_ITEMS_PER_SESSION: usize = 4; +pub(super) type InterimTask = JoinHandle<(InterimEpoch, String)>; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct InterimEpoch(pub(super) u64); + +#[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("{0}")] + PendingPrecommitFailure(String), + #[error("{0}")] + Finalization(String), + #[error(transparent)] + Mailbox(#[from] MailboxError), +} + +#[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) canceled_tasks: Vec, + pub(super) canceled_task_failed: bool, + pub(super) committed: HashMap, + pub(super) previous_item_id: Option, + 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, + 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, + results: ResultMailbox::default(), + } + } +} diff --git a/crates/gateway-stt/src/take.rs b/crates/gateway-stt/src/take.rs index 8a44e77f..b94caa93 100644 --- a/crates/gateway-stt/src/take.rs +++ b/crates/gateway-stt/src/take.rs @@ -1,191 +1,28 @@ //! Per-take speech state and finalization ownership. -use std::future::Future; -use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::sync::Arc; -use gateway_stt_engine::{SttEngine, TranscribeError}; -use tokio::sync::{mpsc, oneshot}; - -use crate::segment::Segmenter; - -#[derive(Debug, PartialEq, Eq)] -struct AgreementSnapshot { - agreed: String, - tentative: String, -} - -#[derive(Debug, Default)] -struct LocalAgreement { - previous: String, -} - -impl LocalAgreement { - fn observe(&mut self, hypothesis: &str) -> AgreementSnapshot { - let agreed_end = if self.previous.is_empty() { - 0 - } else { - matching_token_prefix_end(&self.previous, hypothesis) - }; - self.previous.clear(); - self.previous.push_str(hypothesis); - AgreementSnapshot { - agreed: hypothesis[..agreed_end].to_owned(), - tentative: hypothesis[agreed_end..].to_owned(), - } - } -} - -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) -} - -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 -} +use gateway_stt_engine::SttEngine; +#[cfg(test)] +use gateway_stt_engine::TranscribeError; -fn after_token_prefix(text: &str, tokens: usize) -> &str { - if tokens == 0 { - return text; - } - token_spans(text) - .get(tokens - 1) - .map_or("", |(_, _, end)| &text[*end..]) -} +mod agreement; +mod finalization; +mod state; +mod text; -fn append_transcript(text: &mut String, piece: &str) { - if piece.is_empty() { - return; - } - if !text.is_empty() { - text.push(' '); - } - text.push_str(piece); -} +#[cfg(test)] +use agreement::LocalAgreement; +#[cfg(test)] +use finalization::{FINAL_SEGMENT_CAPACITY, FinalCommand, reserve_segment, run_final_pipeline}; +use finalization::{FinalPipeline, spawn_final_pipeline}; +use state::TakeState; +use text::append_transcript; fn tail(buffer: &[f32], window: usize) -> &[f32] { &buffer[buffer.len().saturating_sub(window)..] } -#[derive(Debug, Default)] -struct FinalizedState { - text: String, - failure: Option, - samples: usize, -} - -#[derive(Debug, Default)] -struct InterimState { - agreement: LocalAgreement, - promoted: String, - agreement_finalized: String, - committed: String, - last_committed: String, - last_tentative: String, - finalized_at_last_speech: String, -} - -#[derive(Debug, Default)] -struct TakeState { - buffer: Mutex>, - segmenter: Mutex, - finalized: Mutex, - interim: Mutex, -} - -impl TakeState { - fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { - mutex.lock().unwrap_or_else(PoisonError::into_inner) - } - - fn finalized(&self) -> String { - Self::lock(&self.finalized).text.clone() - } - - 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(_) => {} - } - } - - fn record_failure(&self, failure: String) { - let mut state = Self::lock(&self.finalized); - if state.failure.is_none() { - state.failure = Some(failure); - } - } - - fn has_failure(&self) -> bool { - Self::lock(&self.finalized).failure.is_some() - } - - fn finalized_samples(&self) -> usize { - Self::lock(&self.finalized).samples - } - - fn completion(&self) -> Result { - let mut state = Self::lock(&self.finalized); - match state.failure.take() { - Some(failure) => Err(failure), - None => Ok(state.text.clone()), - } - } -} - -#[derive(Debug)] -enum FinalCommand { - Segment { - samples: Vec, - end: usize, - }, - Complete { - tail: Vec, - reply: oneshot::Sender>, - }, -} - -#[derive(Debug)] -struct FinalPipeline { - commands: mpsc::UnboundedSender, - task: tokio::task::JoinHandle<()>, -} - -impl Drop for FinalPipeline { - fn drop(&mut self) { - self.task.abort(); - } -} - /// All mutable and immutable state belonging to one speech take. #[derive(Debug)] pub(crate) struct Take { @@ -222,28 +59,8 @@ impl Take { } pub(crate) fn submit_closed_segments(&self) { - let Some(pipeline) = &self.final_pipeline else { - return; - }; - loop { - let segment = { - let buffer = TakeState::lock(&self.state.buffer); - TakeState::lock(&self.state.segmenter) - .poll(&buffer) - .map(|range| (buffer[range.clone()].to_vec(), range.end)) - }; - let Some((samples, end)) = segment else { - break; - }; - if pipeline - .commands - .send(FinalCommand::Segment { samples, end }) - .is_err() - { - self.state - .record_failure("final transcription pipeline exited".to_owned()); - break; - } + if let Some(pipeline) = &self.final_pipeline { + pipeline.submit_closed_segments(&self.state); } } @@ -287,137 +104,42 @@ impl Take { self.state.record_finalized(result, None); } - #[cfg(test)] - fn record_failure(&self, failure: impl Into) { + 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 { - TakeState::lock(&self.state.finalized).failure.take() + self.state.take_failure() } pub(crate) fn next_interim(&self, hypothesis: &str) -> Option<(String, String)> { let finalized = self.finalized(); - let mut state = TakeState::lock(&self.state.interim); - if state.agreement_finalized != finalized { - let finalized_delta = finalized - .strip_prefix(&state.agreement_finalized) - .unwrap_or_default(); - let unpromoted = - after_token_prefix(finalized_delta, token_spans(&state.promoted).len()); - append_transcript(&mut state.committed, unpromoted.trim()); - state.agreement = LocalAgreement::default(); - state.promoted.clear(); - state.agreement_finalized.clone_from(&finalized); - } - let suffix_start = matching_token_prefix_end(&state.promoted, hypothesis); - let suffix = hypothesis[suffix_start..].trim_start(); - let agreement = state.agreement.observe(suffix); - let tentative = agreement.tentative.trim_start().to_owned(); - state.agreement.previous.clone_from(&tentative); - let promoted = agreement.agreed.trim(); - append_transcript(&mut state.promoted, promoted); - append_transcript(&mut state.committed, promoted); - if !hypothesis.is_empty() { - state.finalized_at_last_speech.clone_from(&finalized); - } else if finalized.len() <= state.finalized_at_last_speech.len() { - return None; - } - let committed = state.committed.clone(); - if committed == state.last_committed && tentative == state.last_tentative { - return None; - } - state.last_committed.clone_from(&committed); - state.last_tentative.clone_from(&tentative); - Some((committed, tentative)) + TakeState::lock(&self.state.interim).next(&finalized, hypothesis) } - pub(crate) async fn complete(&self) -> Option> { + pub(crate) fn finalization(&self) -> Option { let pipeline = self.final_pipeline.as_ref()?; - let tail = { - let consumed = self.consumed(); - let buffer = TakeState::lock(&self.state.buffer); - buffer[consumed.min(buffer.len())..].to_vec() - }; - let (reply, reply_rx) = oneshot::channel(); - if pipeline - .commands - .send(FinalCommand::Complete { tail, reply }) - .is_err() - { - return Some(Err("final transcription pipeline exited".to_owned())); - } - Some( - reply_rx - .await - .unwrap_or_else(|_| Err("final transcription pipeline exited".to_owned())), - ) + let consumed = self.consumed(); + let buffer = TakeState::lock(&self.state.buffer); + let tail = buffer[consumed.min(buffer.len())..].to_vec(); + Some(pipeline.finalization(tail)) } -} - -fn spawn_final_pipeline( - engine: Arc, - guidance: Arc<[String]>, - state: Arc, -) -> FinalPipeline { - let (commands, receiver) = mpsc::unbounded_channel(); - let task = tokio::spawn(run_final_pipeline( - receiver, - guidance, - state, - move |samples, guidance, finalized| { - let engine = Arc::clone(&engine); - async move { - if !engine.has_final_pass() { - return None; - } - Some( - engine - .decode(gateway_stt_engine::DecodeRequest::new( - gateway_stt_engine::DecodeMode::Final, - samples, - guidance, - finalized, - )) - .await, - ) - } - }, - )); - FinalPipeline { commands, task } -} -async fn run_final_pipeline( - mut receiver: mpsc::UnboundedReceiver, - guidance: Arc<[String]>, - state: Arc, - mut decode: D, -) where - D: FnMut(Vec, Vec, String) -> F, - F: Future>>, -{ - while let Some(command) = receiver.recv().await { - let (samples, finalized_samples, completion) = match command { - FinalCommand::Segment { samples, end } => (samples, Some(end), None), - FinalCommand::Complete { tail, reply } => (tail, None, Some(reply)), - }; - if !state.has_failure() { - let finalized = state.finalized(); - match decode(samples, guidance.to_vec(), finalized).await { - Some(result) => state.record_finalized(result, finalized_samples), - None => { - state.record_failure("final transcription worker is unavailable".to_owned()); - } - } - } - if let Some(reply) = completion { - let _ = reply.send(state.completion()); - break; - } + pub(crate) async fn complete(&self) -> Option> { + Some(self.finalization()?.await) } - drop(guidance); - drop(state); } #[cfg(test)] @@ -428,7 +150,20 @@ mod tests { use tokio::sync::{mpsc, oneshot}; - use super::{FinalCommand, LocalAgreement, Take, run_final_pipeline}; + use super::{FinalCommand, LocalAgreement, 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() { @@ -572,13 +307,14 @@ mod tests { .concat(), ); - let (commands, receiver) = mpsc::unbounded_channel(); + let (commands, receiver) = mpsc::channel(super::FINAL_SEGMENT_CAPACITY); let calls = Arc::new(AtomicUsize::new(0)); let decode_calls = Arc::clone(&calls); let task = tokio::spawn(run_final_pipeline( receiver, Arc::from([]), Arc::clone(&take.state), + Arc::new(AtomicUsize::new(3)), move |_, _, _| { let call = decode_calls.fetch_add(1, Ordering::SeqCst); async move { @@ -595,18 +331,21 @@ mod tests { samples: successful, end: 4, }) + .await .expect("the successful segment queues"); commands .send(FinalCommand::Segment { samples: failed.clone(), end: 7, }) + .await .expect("the failed segment queues"); commands .send(FinalCommand::Segment { samples: skipped.clone(), end: 9, }) + .await .expect("the skipped segment queues"); let (reply, completion) = oneshot::channel(); commands @@ -614,6 +353,7 @@ mod tests { tail: tail.clone(), reply, }) + .await .expect("completion queues"); assert!( @@ -635,7 +375,7 @@ mod tests { #[tokio::test] async fn completed_pipeline_releases_its_retained_dependency() { - let (commands, receiver) = mpsc::unbounded_channel(); + 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); @@ -644,6 +384,7 @@ mod tests { receiver, Arc::from([]), state, + Arc::new(AtomicUsize::new(0)), move |_, _, _| { let retained = Arc::clone(&pipeline_retained); async move { @@ -659,6 +400,7 @@ mod tests { tail: Vec::new(), reply, }) + .await .expect("completion queues"); assert_eq!( diff --git a/crates/gateway-stt/src/take/agreement.rs b/crates/gateway-stt/src/take/agreement.rs new file mode 100644 index 00000000..70ee050c --- /dev/null +++ b/crates/gateway-stt/src/take/agreement.rs @@ -0,0 +1,116 @@ +use super::text::append_transcript; + +#[derive(Debug, PartialEq, Eq)] +pub(super) struct AgreementSnapshot { + pub(super) agreed: String, + pub(super) tentative: String, +} + +#[derive(Debug, Default)] +pub(super) struct LocalAgreement { + previous: String, +} + +impl LocalAgreement { + pub(super) fn observe(&mut self, hypothesis: &str) -> AgreementSnapshot { + let agreed_end = if self.previous.is_empty() { + 0 + } else { + matching_token_prefix_end(&self.previous, hypothesis) + }; + self.previous.clear(); + self.previous.push_str(hypothesis); + AgreementSnapshot { + agreed: hypothesis[..agreed_end].to_owned(), + tentative: hypothesis[agreed_end..].to_owned(), + } + } +} + +#[derive(Debug, Default)] +pub(super) struct InterimState { + agreement: LocalAgreement, + promoted: String, + agreement_finalized: String, + committed: String, + last_committed: String, + last_tentative: String, + finalized_at_last_speech: String, +} + +impl InterimState { + pub(super) fn next(&mut self, finalized: &str, hypothesis: &str) -> Option<(String, String)> { + if self.agreement_finalized != finalized { + let finalized_delta = finalized + .strip_prefix(&self.agreement_finalized) + .unwrap_or_default(); + let unpromoted = after_token_prefix(finalized_delta, token_spans(&self.promoted).len()); + append_transcript(&mut self.committed, unpromoted.trim()); + self.agreement = LocalAgreement::default(); + self.promoted.clear(); + self.agreement_finalized.clear(); + self.agreement_finalized.push_str(finalized); + } + let suffix_start = matching_token_prefix_end(&self.promoted, hypothesis); + let suffix = hypothesis[suffix_start..].trim_start(); + let agreement = self.agreement.observe(suffix); + let tentative = agreement.tentative.trim_start().to_owned(); + self.agreement.previous.clone_from(&tentative); + let promoted = agreement.agreed.trim(); + append_transcript(&mut self.promoted, promoted); + append_transcript(&mut self.committed, promoted); + if !hypothesis.is_empty() { + self.finalized_at_last_speech.clear(); + self.finalized_at_last_speech.push_str(finalized); + } else if finalized.len() <= self.finalized_at_last_speech.len() { + return None; + } + let committed = self.committed.clone(); + if committed == self.last_committed && tentative == self.last_tentative { + return None; + } + self.last_committed.clone_from(&committed); + self.last_tentative.clone_from(&tentative); + Some((committed, tentative)) + } +} + +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) +} + +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 +} + +fn after_token_prefix(text: &str, tokens: usize) -> &str { + if tokens == 0 { + return text; + } + token_spans(text) + .get(tokens - 1) + .map_or("", |(_, _, end)| &text[*end..]) +} diff --git a/crates/gateway-stt/src/take/finalization.rs b/crates/gateway-stt/src/take/finalization.rs new file mode 100644 index 00000000..944c81c6 --- /dev/null +++ b/crates/gateway-stt/src/take/finalization.rs @@ -0,0 +1,175 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use gateway_stt_engine::{DecodeMode, DecodeRequest, SttEngine, TranscribeError}; +use tokio::sync::{mpsc, oneshot}; + +use super::state::TakeState; + +pub(super) type TakeFinalization = Pin> + Send>>; +pub(super) const FINAL_SEGMENT_CAPACITY: usize = 4; + +#[derive(Debug)] +pub(super) enum FinalCommand { + Segment { + samples: Vec, + end: usize, + }, + Complete { + tail: 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 segment = { + let buffer = TakeState::lock(&state.buffer); + TakeState::lock(&state.segmenter) + .poll(&buffer) + .map(|range| (buffer[range.clone()].to_vec(), range.end)) + }; + let Some((samples, end)) = segment else { + break; + }; + if !reserve_segment(&self.pending_segments) { + state.record_failure("final segment capacity is reached".to_owned()); + break; + } + match self + .commands + .try_send(FinalCommand::Segment { samples, end }) + { + 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) -> TakeFinalization { + let commands = self.commands.clone(); + Box::pin(async move { + let (reply, reply_rx) = oneshot::channel(); + if commands + .send(FinalCommand::Complete { tail, 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: Arc, + 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 = Arc::clone(&engine); + 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 { + let (samples, finalized_samples, completion) = match command { + FinalCommand::Segment { samples, end } => (samples, Some(end), None), + FinalCommand::Complete { tail, reply } => (tail, None, Some(reply)), + }; + if !state.has_failure() { + let finalized = state.finalized(); + match decode(samples, guidance.to_vec(), finalized).await { + Some(result) => state.record_finalized(result, finalized_samples), + None => { + state.record_failure("final transcription worker is unavailable".to_owned()); + } + } + } + if finalized_samples.is_some() { + pending_segments.fetch_sub(1, Ordering::AcqRel); + } + if let Some(reply) = completion { + drop(reply.send(state.completion())); + break; + } + } +} diff --git a/crates/gateway-stt/src/take/state.rs b/crates/gateway-stt/src/take/state.rs new file mode 100644 index 00000000..8c2c9226 --- /dev/null +++ b/crates/gateway-stt/src/take/state.rs @@ -0,0 +1,82 @@ +use std::sync::{Mutex, MutexGuard, PoisonError}; + +use gateway_stt_engine::TranscribeError; + +use super::agreement::InterimState; +use super::text::append_transcript; +use crate::segment::Segmenter; + +#[derive(Debug, Default)] +struct FinalizedState { + text: String, + failure: Option, + samples: usize, +} + +#[derive(Debug, Default)] +pub(super) struct TakeState { + pub(super) buffer: Mutex>, + pub(super) segmenter: Mutex, + finalized: Mutex, + pub(super) interim: 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 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_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 finalized_samples(&self) -> usize { + Self::lock(&self.finalized).samples + } + + 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) -> Result { + let mut state = Self::lock(&self.finalized); + match state.failure.take() { + Some(failure) => Err(failure), + None => Ok(state.text.clone()), + } + } +} 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/test_fixtures.rs b/crates/gateway-stt/src/test_fixtures.rs index b0e9f04d..7e684b0f 100644 --- a/crates/gateway-stt/src/test_fixtures.rs +++ b/crates/gateway-stt/src/test_fixtures.rs @@ -11,11 +11,13 @@ pub use gateway_stt_engine::test_fixtures::{ScriptedDecoder, ScriptedModelFactor #[cfg(feature = "test-fixtures")] use crate::SttRuntime; #[cfg(feature = "test-fixtures")] -use crate::realtime::{InterimEpoch, Session, SessionRegistry}; +use crate::realtime::{CommitReceipt, InterimEpoch, ItemResult, Session, SessionRegistry}; #[cfg(feature = "test-fixtures")] use gateway_stt_engine::{EnginePolicy, SttEngine, TranscribeError}; #[cfg(feature = "test-fixtures")] use std::future::Future; +#[cfg(feature = "test-fixtures")] +use std::sync::Arc; /// Builds a speech runtime around deterministic scripted workers. /// @@ -59,11 +61,34 @@ impl RealtimeSessionRegistryFixture { }) } + /// 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 policy = EnginePolicy::new(15, 500, factory.gpu_available()) + .map_err(|error| error.to_string())?; + let engine = SttEngine::new(factory, policy).map_err(|error| error.to_string())?; + Ok(RealtimeSessionFixture { + session: Session::new(registration, Some(Arc::new(engine))), + }) + } + /// Returns active and still-retiring session ownership. #[must_use] pub fn active(&self) -> usize { self.inner.active() } + + /// Returns owned admission without polling retiring task destructors. + #[must_use] + pub fn owned_without_reaping(&self) -> usize { + self.inner.owned_without_reaping() + } } /// An opaque interim epoch used by the Realtime session fixture. @@ -80,6 +105,26 @@ pub struct RealtimeInputSnapshotFixture { 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. @@ -156,6 +201,121 @@ impl RealtimeSessionFixture { .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() + } + + /// 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()) + } + /// Begins an interim epoch without spawning work. /// /// # Errors @@ -232,6 +392,45 @@ impl RealtimeSessionFixture { } } +#[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, message } => serde_json::json!({ + "type": "failed", + "item_id": item_id, + "message": message, + }), + } +} + #[cfg(test)] pub(crate) fn require_model() -> PathBuf { require_fixture("PROMPTFORGE_WHISPER_MODEL", "ggml-tiny.en.bin") diff --git a/crates/gateway-stt/tests/it/architecture.rs b/crates/gateway-stt/tests/it/architecture.rs index 3ee375c1..92ecad67 100644 --- a/crates/gateway-stt/tests/it/architecture.rs +++ b/crates/gateway-stt/tests/it/architecture.rs @@ -75,7 +75,7 @@ const DEPENDENCY_POLICIES: [DependencyPolicy; 7] = [ ], temporary_edges: &[TemporaryEdge { dependency: "workshop-server", - removal_step: "Step 28", + removal_step: "Step 29", }], }, DependencyPolicy { @@ -124,24 +124,19 @@ const MIGRATION_POLICIES: [MigrationPolicy; 4] = [ targets: &[ MigrationPolicyTarget { module: "api.rs", - target_step: "Step 17", + target_step: "Step 18", destination: "batch.rs", }, MigrationPolicyTarget { module: "runtime.rs", - target_step: "Step 17", + target_step: "Step 18", destination: "service.rs, artifacts.rs, generation.rs, status.rs, and model.rs", }, MigrationPolicyTarget { module: "stt.rs", - target_step: "Step 28", + target_step: "Step 29", destination: "removal after the Realtime route and Workshop relay replace the legacy socket", }, - MigrationPolicyTarget { - module: "take.rs", - target_step: "Step 16", - destination: "independent committed-item finalization", - }, ], }, MigrationPolicy { @@ -512,14 +507,14 @@ fn gateway_step_15_migrations_are_pinned_to_their_destinations() { assert_eq!( expected["api.rs"], MigrationTarget { - target_step: "Step 17".to_owned(), + target_step: "Step 18".to_owned(), destination: "batch.rs".to_owned(), } ); assert_eq!( expected["runtime.rs"], MigrationTarget { - target_step: "Step 17".to_owned(), + target_step: "Step 18".to_owned(), destination: "service.rs, artifacts.rs, generation.rs, status.rs, and model.rs" .to_owned(), } diff --git a/crates/gateway-stt/tests/it/realtime_session.rs b/crates/gateway-stt/tests/it/realtime_session.rs index b07412f2..39b2690c 100644 --- a/crates/gateway-stt/tests/it/realtime_session.rs +++ b/crates/gateway-stt/tests/it/realtime_session.rs @@ -1,15 +1,21 @@ use std::future::{Future, pending}; use std::pin::Pin; -use std::sync::Arc; 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}; +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 @@ -19,6 +25,12 @@ fn encoded(samples: &[i16]) -> String { 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", @@ -45,28 +57,42 @@ fn session() -> RealtimeSessionFixture { .expect("session registers") } -struct BlockingDrop { - dropping: Arc, +struct BlockingPoll { + started: Arc<(Mutex, Condvar)>, release: Arc, } -impl Future for BlockingDrop { +impl Future for BlockingPoll { type Output = String; fn poll(self: Pin<&mut Self>, _context: &mut Context<'_>) -> Poll { - Poll::Pending - } -} - -impl Drop for BlockingDrop { - fn drop(&mut self) { - self.dropping.store(true, Ordering::Release); + 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()) } } +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) { for _ in 0..1_000 { if predicate() { @@ -153,24 +179,35 @@ fn failed_first_append_does_not_capture_configuration() { } #[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"); session .append_base64(&encoded(&[0, 0])) .expect("input appends"); - let dropping = Arc::new(AtomicBool::new(false)); + let started = Arc::new((Mutex::new(false), Condvar::new())); let release = Arc::new(AtomicBool::new(false)); session - .spawn_interim(BlockingDrop { - dropping: Arc::clone(&dropping), + .spawn_interim(BlockingPoll { + started: Arc::clone(&started), release: Arc::clone(&release), }) .expect("interim starts"); + wait_until_started(&started); drop(session); - wait_until(|| dropping.load(Ordering::Acquire)).await; - assert_eq!(registry.active(), 1, "retiring work keeps admission owned"); + assert_eq!( + registry.owned_without_reaping(), + 1, + "retiring work keeps admission owned" + ); release.store(true, Ordering::Release); wait_until(|| registry.active() == 0).await; } @@ -201,21 +238,28 @@ async fn canceling_finish_keeps_current_task_owned_for_retry() { } #[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 dropping = Arc::new(AtomicBool::new(false)); + let started = Arc::new((Mutex::new(false), Condvar::new())); let release = Arc::new(AtomicBool::new(false)); session - .spawn_interim(BlockingDrop { - dropping: Arc::clone(&dropping), + .spawn_interim(BlockingPoll { + started: Arc::clone(&started), release: Arc::clone(&release), }) .expect("interim starts"); + wait_until_started(&started); session.clear().expect("interim retires"); - wait_until(|| dropping.load(Ordering::Acquire)).await; assert!( session.join_canceled().now_or_never().is_none(), @@ -285,3 +329,397 @@ fn stale_interim_is_rejected_before_event_id_allocation() { "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() +} + +#[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(); + final_decoder.park_next(); + 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(); + for index in 0..COMMITTED_ITEM_CAPACITY { + session + .update_text(&update(&format!("prompt-{index}"), true)) + .expect("item prompt updates"); + append_committable(&mut session); + ids.push(session.commit().expect("item commits").item_id().to_owned()); + } + assert_eq!( + session.finalizing_count(), + COMMITTED_ITEM_CAPACITY, + "every committed take owns an asynchronous finalization" + ); + tokio::task::yield_now().await; + assert!( + final_decoder.wait_until_parked(Duration::from_secs(1)), + "one accurate final decode parks while all item tasks remain owned" + ); + final_decoder.release(); + + 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"); + final_decoder.park_next(); + let registry = RealtimeSessionRegistryFixture::default(); + let mut session = registry + .register_with_scripted_engine( + ScriptedModelFactory::new(interim).with_final(final_decoder.clone()), + ) + .expect("scripted session starts"); + append_committable(&mut session); + let item = session.commit().expect("item commits"); + let item_id = item.item_id().to_owned(); + + tokio::task::yield_now().await; + assert!( + final_decoder.wait_until_parked(Duration::from_secs(1)), + "the accurate decode remains parked" + ); + 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); + final_decoder.release(); + 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"); + final_decoder.park_next(); + let registry = RealtimeSessionRegistryFixture::default(); + let mut session = registry + .register_with_scripted_engine( + ScriptedModelFactory::new(interim).with_final(final_decoder.clone()), + ) + .expect("scripted session starts"); + + session + .append_base64(&closed_segment()) + .expect("closed segment enters the production take"); + tokio::task::yield_now().await; + assert!( + final_decoder.wait_until_parked(Duration::from_secs(1)), + "the accurate segment is running between appends" + ); + final_decoder.release(); + 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.park_next(); + 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"); + + session + .append_base64(&closed_segment()) + .expect("first closed segment is admitted"); + tokio::task::yield_now().await; + assert!( + final_decoder.wait_until_parked(Duration::from_secs(1)), + "the first production segment parks in final decoding" + ); + 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"); + final_decoder.release(); +} + +#[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/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 52d732a2..e220151a 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -29,6 +29,9 @@ todos: - 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: pending isProject: false --- @@ -53,7 +56,7 @@ isProject: false - 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 30 are the sole logging exception. + - 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 31 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. @@ -230,6 +233,7 @@ isProject: false - 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. - 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. @@ -342,7 +346,7 @@ isProject: false 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 22: `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 23 through 27, adds only `workshop-server -> shared-loopback`. Final Phase C, Step 28 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. +The architecture harness enforces exact workspace-package edges across normal, development, and build dependencies, ignoring self-dependencies. Phase A, Steps 7 through 23: `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 24 through 28, adds only `workshop-server -> shared-loopback`. Final Phase C, Step 29 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] @@ -511,7 +515,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. @@ -522,7 +526,16 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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: Replace runtime and route APIs atomically +### Step 17: Use preinstalled Rust on the native runner + +- 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 - 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`. @@ -532,9 +545,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 14; every current reverse consumer compiles and tests in this API-changing commit. +- Consumes and gates: consumes Steps 10 and 16; every current reverse consumer compiles and tests in this API-changing commit. -### Step 18: Quiesce generations with explicit ownership +### Step 19: Quiesce generations with explicit ownership - 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. @@ -545,7 +558,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 19: Make profile replacement transactional +### Step 20: Make profile replacement transactional - 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. @@ -554,9 +567,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 18; cancellation-at-every-await and rollback outcomes gate route mounting. +- Consumes and gates: consumes Step 19; cancellation-at-every-await and rollback outcomes gate route mounting. -### Step 20: Separate origin predicates +### Step 21: Separate origin predicates - 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. @@ -564,7 +577,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 21: Integrate generic speech facts +### Step 22: Integrate generic speech facts - 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. @@ -574,9 +587,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 17 facade and Step 19 lifecycle; status correctness gates route publication. +- Consumes and gates: consumes Step 18 facade and Step 20 lifecycle; status correctness gates route publication. -### Step 22: Mount the additive Gateway route +### Step 23: Mount the additive Gateway route - 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. @@ -584,36 +597,36 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 21; the independent Gateway fixture path gates Workshop relay work. +- Consumes and gates: consumes Steps 12 through 22; the independent Gateway fixture path gates Workshop relay work. -### Step 23: Add the Workshop relay beside legacy +### Step 24: Add the Workshop relay beside legacy - 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 20 Workshop predicate and Step 22 public fixtures, but adds no dependency on Gateway or gateway-stt. +- Consumes and gates: consumes Step 21 Workshop predicate and Step 23 public fixtures, but adds no dependency on Gateway or gateway-stt. -### Step 24: Prove the actual worklet bytes +### Step 25: Prove the actual worklet bytes - 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 23 additive relay; byte parity gates browser migration. +- Consumes and gates: consumes Step 11 language-neutral bytes and Step 24 additive relay; byte parity gates browser migration. -### Step 25: Migrate Workshop browser speech +### Step 26: Migrate Workshop browser speech - 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, 21, and 22; browser acceptance gates independent full-path automation. +- Consumes and gates: consumes Steps 3, 24, and 25; browser acceptance gates independent full-path automation. -### Step 26: Prove both fixture-driven halves +### Step 27: Prove both fixture-driven halves - 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. @@ -621,9 +634,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 22 through 25; both independent halves must pass before packaging. +- Consumes and gates: consumes Steps 23 through 26; both independent halves must pass before packaging. -### Step 27: Pass installed Windows microphone acceptance +### Step 28: Pass installed Windows microphone acceptance - 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` steps `Build and stage the gateway sidecar`, `Build the app`, and `Install and check (Windows)`, then record installed-package microphone revision, completion, second take, clear, cancellation, and recoverable permission or device failure with binary hashes and timestamps. @@ -634,12 +647,12 @@ The architecture harness enforces exact workspace-package edges across normal, d - `C:\Users\Vinnie\cursor\promptforge\crates\workshop`: `if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY)) { throw 'TAURI_SIGNING_PRIVATE_KEY is required by the release workflow' }; if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD)) { throw 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD is required by the release workflow' }; $env:RUSTUP_TOOLCHAIN='stable'; cargo tauri build --bundles nsis` - `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 26; the operator must exercise the installed application and record passing evidence, which alone gates legacy removal. +- Consumes and gates: consumes Step 27; the operator must exercise the installed application and record passing evidence, which alone gates legacy removal. -### Step 28: Remove legacy seams and tests +### Step 29: Remove legacy seams and tests - 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 Step 3, 20, 21, 23, or 24 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. +- Scope: map every retired legacy assertion to Step 3, 23, 24, 26, or 27 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` @@ -650,7 +663,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 29: Finalize architecture and documentation +### Step 30: Finalize architecture and documentation - 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. @@ -660,9 +673,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 28 final topology; final verification starts only with zero temporary exceptions. +- Consumes and gates: consumes Step 29 final topology; final verification starts only with zero temporary exceptions. -### Step 30: Bookend Gateway serving logs +### Step 31: Bookend Gateway serving logs - 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. @@ -670,9 +683,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 29 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 31's full release verification must pass after this change. +- Consumes and gates: this operator-requested observability correction is independent of STT and follows Step 30 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 32's full release verification must pass after this change. -### Step 31: Run every release gate and repeat acceptance +### Step 32: Run every release gate and repeat acceptance - Artifacts: append command results, hashes, ratchet counts, native equivalence, generated-doc cleanliness, and repeated installed-microphone evidence to `design/generic-realtime-stt-acceptance.md`; change no implementation. - Scope: run every exit criterion independently under PowerShell 5.1 and stop on any failure. @@ -706,6 +719,6 @@ The architecture harness enforces exact workspace-package edges across normal, d - `C:\Users\Vinnie\cursor\promptforge\crates\workshop`: `if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY)) { throw 'TAURI_SIGNING_PRIVATE_KEY is required by the release workflow' }; if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD)) { throw 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD is required by the release workflow' }; $env:RUSTUP_TOOLCHAIN='stable'; cargo tauri build --bundles nsis` - `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 30, then repeats the Step 27 installed-package microphone scenarios. Completion requires every command, ratchet, generated-doc check, and physical scenario to pass with no open finding. +- Consumes and gates: consumes Step 31, then repeats the Step 28 installed-package microphone scenarios. 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 30's exact two records, or completed Workshop baseline-ratchet work. VAD tuning, alignment, 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 +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 31's exact two records, or completed Workshop baseline-ratchet work. VAD tuning, alignment, 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/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 09192942..f3060f27 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -130,8 +130,8 @@ N1 | observation | Violates A2 @ crates/gateway-stt/tests/fixtures/realtime: not N2 | observation | Violates A96 @ crates/workshop-server/ui/test/realtime-wire-fixtures.mjs: not determinable from diff | Freeze the realtime transcription wire contract 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 -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 +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 +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 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 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 @@ -149,14 +149,14 @@ N20 | observation | surface-growth @ crates/gateway-stt-engine/src/lib.rs::test_ 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 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 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 +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 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 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 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 +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 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 +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 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* From b6021e4c757a360d9b2f8097fabbce310d5445d3 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 10:35:17 -0700 Subject: [PATCH 25/86] Use preinstalled Rust on native runners Make the self-hosted native job use its provisioned stable Rust toolchain. Fail before caching when required binaries or the stable toolchain are absent, then expose the tool directory to later steps. Add source tests that preserve the hosted interpreter setup and reject installer regressions. - `.github/workflows/stt-miri.yml` replaces the native toolchain installer with a preflight that resolves `rustup.exe` and `cargo.exe` under `$env:USERPROFILE`, disables automatic installation, and writes `$cargoBin` to `$env:GITHUB_PATH`. - `Verify preinstalled stable Rust` lists installed toolchains, requires a stable entry, and invokes `$cargo` with `+stable`; each failed precondition throws a provisioning error. - `tools/check-stt-native-workflow.test.mjs` pins preflight order and failure text, rejects installer actions in the native job, and confirms that `pure-stt-state` keeps `nightly-2026-09-05`. Design: new hidden-dependency @ .github/workflows/stt-miri.yml boundary: persisted Design: new temporal-coupling @ .github/workflows/stt-miri.yml boundary: persisted Design: new oversized-unit @ tools/check-stt-native-workflow.test.mjs Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- .github/workflows/stt-miri.yml | 27 ++++++- tools/check-stt-native-workflow.test.mjs | 92 +++++++++++++++++++++++ vibe/2026-09-05-2-generic-realtime-stt.md | 4 +- 3 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 tools/check-stt-native-workflow.test.mjs diff --git a/.github/workflows/stt-miri.yml b/.github/workflows/stt-miri.yml index 3a2b627e..91dedcb8 100644 --- a/.github/workflows/stt-miri.yml +++ b/.github/workflows/stt-miri.yml @@ -51,7 +51,32 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable + - name: Verify preinstalled stable Rust + shell: powershell + run: | + $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)) { + throw 'self-hosted runner Rust is not provisioned: missing rustup.exe' + } + if (-not (Test-Path $cargo -PathType Leaf)) { + throw 'self-hosted runner Rust is not provisioned: missing cargo.exe' + } + $env:RUSTUP_AUTO_INSTALL = '0' + $cargoBin | Add-Content $env:GITHUB_PATH + $toolchains = @(& $rustup toolchain list) + if ($LASTEXITCODE -ne 0) { + throw 'self-hosted runner Rust is not provisioned: rustup toolchain list failed' + } + $stableToolchain = $toolchains | Where-Object { $_ -match '^stable(?:-|\s|$)' } | Select-Object -First 1 + if (-not $stableToolchain) { + throw 'self-hosted runner Rust is not provisioned: stable toolchain is missing' + } + & $cargo '+stable' '--version' + if ($LASTEXITCODE -ne 0) { + throw 'self-hosted runner Rust is not provisioned: stable toolchain is unavailable' + } - name: Cache Cargo uses: Swatinem/rust-cache@v2 diff --git a/tools/check-stt-native-workflow.test.mjs b/tools/check-stt-native-workflow.test.mjs new file mode 100644 index 00000000..b159bd25 --- /dev/null +++ b/tools/check-stt-native-workflow.test.mjs @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const workflow = readFileSync( + join(root, ".github", "workflows", "stt-miri.yml"), + "utf8", +); + +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); +} + +test("native runner validates provisioned stable Rust before caching", () => { + const native = jobSource("native-whisper"); + const preflight = native.indexOf("- name: Verify preinstalled stable Rust"); + const cache = native.indexOf("- name: Cache Cargo"); + const disableAutoInstall = native.indexOf( + "$env:RUSTUP_AUTO_INSTALL = '0'", + ); + const listToolchains = native.indexOf( + "$toolchains = @(& $rustup toolchain list)", + ); + const requireStable = native.indexOf("if (-not $stableToolchain)"); + const invokeCargo = native.indexOf("& $cargo '+stable' '--version'"); + + assert.ok(preflight > 0, "native job must have a Rust preflight"); + assert.ok(cache > preflight, "Rust preflight must run before Cargo caching"); + assert.ok( + disableAutoInstall > preflight, + "native preflight must disable rustup auto-install", + ); + assert.ok( + listToolchains > disableAutoInstall, + "native preflight must inspect installed toolchains after disabling auto-install", + ); + assert.ok( + requireStable > listToolchains, + "native preflight must reject a missing stable toolchain", + ); + assert.ok( + invokeCargo > requireStable, + "missing stable must fail before Cargo runs", + ); + assert.match(native, /Join-Path \$env:USERPROFILE '\.cargo\\bin'/); + assert.match(native, /Join-Path \$cargoBin 'rustup\.exe'/); + assert.match(native, /Join-Path \$cargoBin 'cargo\.exe'/); + assert.match(native, /\$cargoBin \| Add-Content \$env:GITHUB_PATH/); + assert.match(native, /\$toolchains = @\(& \$rustup toolchain list\)/); + assert.match( + native, + /\$stableToolchain = \$toolchains \| Where-Object \{ \$_ -match '\^stable\(\?:-\|\\s\|\$\)' \} \| Select-Object -First 1/, + ); + assert.match(native, /& \$cargo '\+stable' '--version'/); +}); + +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("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 preflight reports clear provisioning failures", () => { + const native = jobSource("native-whisper"); + + assert.match(native, /self-hosted runner Rust is not provisioned: missing rustup\.exe/); + assert.match(native, /self-hosted runner Rust is not provisioned: missing cargo\.exe/); + assert.match(native, /self-hosted runner Rust is not provisioned: rustup toolchain list failed/); + assert.match(native, /self-hosted runner Rust is not provisioned: stable toolchain is missing/); + assert.match(native, /self-hosted runner Rust is not provisioned: stable toolchain is unavailable/); +}); diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index e220151a..95530c53 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -31,7 +31,7 @@ todos: status: completed - id: ci-native-rustup content: Use the self-hosted Windows runner's preinstalled Rust without reinstalling rustup - status: pending + status: completed isProject: false --- @@ -526,7 +526,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. From 2550188328eb3a42819a1cf04e6c5a71d13ed1f2 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 11:05:43 -0700 Subject: [PATCH 26/86] Replace the STT runtime with a speech facade Unify speech lifecycle, model facts, and routes behind one cloneable service so each caller observes one complete generation. Stage verified artifacts and worker state before publication, preserve batch and temporary legacy behavior through service methods, and reduce the production root to six types. - `SpeechService` owns preparation, staged replacement, publication, shutdown, status, model discovery, and route construction through one public facade. - `GenerationState` stores the engine, physical names, guidance, backend, admission, and generation identifier in one lock-published snapshot shared by service clones. - `module-ceilings.toml` fixes the public-root budget at six and removes completed migration targets for `api.rs` and `runtime.rs`. - `batch::routes` owns the upload limit and OpenAI error envelopes while `authorize_stt_route` applies authentication and cancellation to every speech route. - `scripted_service` and `service.rs` pin complete clone snapshots, physical model selection, temporary legacy capability, unload, and Gateway error envelopes. - `Admission` has only an open state, and `unload` waits without a deadline for generation and engine references. Bounded quiescence remains absent. Design: new encapsulated-invariant @ crates/gateway-stt/src/artifacts.rs::PreparedSpeech boundary: pub Design: new oversized-unit @ crates/gateway-stt/src/artifacts.rs Design: replaces oversized-unit @ crates/gateway-stt/src/batch.rs was: crates/gateway-stt/src/api.rs Design: new oversized-unit @ crates/gateway-stt/src/batch/native_tests.rs Design: new oversized-unit @ crates/gateway-stt/src/batch/tests.rs Design: new parameter-object @ crates/gateway-stt/src/generation.rs::Generation Design: replaces shared-mutable-state @ crates/gateway-stt/src/generation.rs::GenerationState was: crates/gateway-stt/src/runtime.rs::SttSlot Design: new encapsulated-invariant @ crates/gateway-stt/src/generation.rs::SpeechReplacement boundary: pub Design: replaces hidden-dependency @ crates/gateway-stt/src/generation.rs::unload deps: Option was: crates/gateway-stt/src/runtime.rs::unload_engine Design: new oversized-unit @ crates/gateway-stt/src/generation.rs Design: new newtype @ crates/gateway-stt/src/model.rs::SpeechModelInfo boundary: pub Design: new facade @ crates/gateway-stt/src/service.rs::SpeechService boundary: pub Design: new temporal-coupling @ crates/gateway-stt/src/service.rs::SpeechService::commit_replacement Design: new oversized-unit @ crates/gateway-stt/src/service.rs Design: new value-object @ crates/gateway-stt/src/status.rs::SpeechStatus boundary: pub Design: replaces constructor-injection @ crates/gateway-stt/src/test_fixtures.rs::scripted_service deps: ScriptedModelFactory,u64,u64 was: crates/gateway-stt/src/runtime.rs::SttRuntime::from_scripted_engine Design: new pure-function @ crates/gateway-stt/src/test_fixtures.rs::segment_ranges deps: &[f32] boundary: pub Design: replaces surface-growth @ crates/gateway-stt/src/test_fixtures.rs::segment_ranges boundary: pub was: crates/gateway-stt/src/lib.rs::Segmenter Design: extends facade @ crates/gateway-stt/src/lib.rs::test_fixtures Design: extends oversized-unit @ crates/gateway-stt/src/test_fixtures.rs Violates: A2 - credential ownership in SpeechService is not determinable from diff Violates: A115 - control readiness during speech provisioning is not determinable from diff Pending: N6 - compounds Pending: N24 - compounds Pending: N25 - compounds Deferred: generation admission has no closed state Deferred: generation unload has no finite wait bound Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- crates/gateway-stt/module-ceilings.toml | 26 +- crates/gateway-stt/src/api.rs | 625 ------------------ crates/gateway-stt/src/artifacts.rs | 294 ++++++++ crates/gateway-stt/src/batch.rs | 348 ++++++++++ crates/gateway-stt/src/batch/native_tests.rs | 113 ++++ crates/gateway-stt/src/batch/tests.rs | 160 +++++ crates/gateway-stt/src/generation.rs | 247 +++++++ crates/gateway-stt/src/lib.rs | 39 +- crates/gateway-stt/src/model.rs | 55 ++ crates/gateway-stt/src/runtime.rs | 455 ------------- crates/gateway-stt/src/segment.rs | 12 +- crates/gateway-stt/src/service.rs | 104 +++ crates/gateway-stt/src/status.rs | 54 ++ crates/gateway-stt/src/stt.rs | 64 +- crates/gateway-stt/src/test_fixtures.rs | 39 +- crates/gateway-stt/tests/common/mod.rs | 86 ++- .../tests/common/native_runtime.rs | 22 +- crates/gateway-stt/tests/it/architecture.rs | 63 +- crates/gateway-stt/tests/it/batch.rs | 10 +- crates/gateway-stt/tests/it/legacy_stream.rs | 26 +- crates/gateway-stt/tests/it/main.rs | 2 + crates/gateway-stt/tests/it/service.rs | 67 ++ crates/gateway/src/error.rs | 58 -- crates/gateway/src/lib.rs | 183 +++-- crates/gateway/src/runner.rs | 16 +- crates/gateway/src/test_support.rs | 58 +- vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- vibe/archdoc-next.md | 13 +- 28 files changed, 1819 insertions(+), 1422 deletions(-) delete mode 100644 crates/gateway-stt/src/api.rs create mode 100644 crates/gateway-stt/src/artifacts.rs create mode 100644 crates/gateway-stt/src/batch.rs create mode 100644 crates/gateway-stt/src/batch/native_tests.rs create mode 100644 crates/gateway-stt/src/batch/tests.rs create mode 100644 crates/gateway-stt/src/generation.rs create mode 100644 crates/gateway-stt/src/model.rs delete mode 100644 crates/gateway-stt/src/runtime.rs create mode 100644 crates/gateway-stt/src/service.rs create mode 100644 crates/gateway-stt/src/status.rs create mode 100644 crates/gateway-stt/tests/it/service.rs diff --git a/crates/gateway-stt/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index 3d705584..5ce4850d 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -2,24 +2,21 @@ # Physical lines include comments and blanks. A source file may shrink but # may not exceed its recorded ceiling. -public_root_budget = 9 - -[migration_targets."api.rs"] -target_step = "Step 18" -destination = "batch.rs" - -[migration_targets."runtime.rs"] -target_step = "Step 18" -destination = "service.rs, artifacts.rs, generation.rs, status.rs, and model.rs" +public_root_budget = 6 [migration_targets."stt.rs"] target_step = "Step 29" destination = "removal after the Realtime route and Workshop relay replace the legacy socket" [modules] -"api.rs" = 625 +"artifacts.rs" = 294 "audio.rs" = 397 -"lib.rs" = 30 +"batch.rs" = 349 +"batch/native_tests.rs" = 113 +"batch/tests.rs" = 160 +"generation.rs" = 247 +"lib.rs" = 40 +"model.rs" = 55 "realtime/mod.rs" = 16 "realtime/input.rs" = 198 "realtime/item.rs" = 157 @@ -34,12 +31,13 @@ destination = "removal after the Realtime route and Workshop relay replace the l "realtime/wire/server.rs" = 389 "realtime/wire/shared.rs" = 218 "realtime/wire/tests.rs" = 278 -"runtime.rs" = 459 "segment.rs" = 239 -"stt.rs" = 733 +"service.rs" = 104 +"status.rs" = 54 +"stt.rs" = 725 "take.rs" = 420 "take/agreement.rs" = 116 "take/finalization.rs" = 175 "take/state.rs" = 82 "take/text.rs" = 10 -"test_fixtures.rs" = 470 +"test_fixtures.rs" = 480 diff --git a/crates/gateway-stt/src/api.rs b/crates/gateway-stt/src/api.rs deleted file mode 100644 index ada16038..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_stt_engine::{DecodeMode, DecodeRequest, EnginePolicy}; -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, guidance)) = state.select(&form.model) else { - return Err(TranscriptionError::ModelNotFound(form.model)); - }; - let (samples, duration) = decode_wav(&form.file)?; - let mode = match role { - LoadedModelRole::Interim => DecodeMode::Interim, - LoadedModelRole::Final => DecodeMode::Final, - }; - let text = engine - .decode(DecodeRequest::new(mode, samples, guidance, String::new())) - .await - .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 / 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(), - }) - } - } -} - -/// 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_stt_engine::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; - mod native_runtime { - #[rustfmt::skip] - include!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/common/native_runtime.rs")); - } - 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 = 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 state = SttState::default(); - let runtime = native_runtime::start(config, state.clone()); - let samples = crate::test_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); - native_runtime::shutdown(runtime); - } -} diff --git a/crates/gateway-stt/src/artifacts.rs b/crates/gateway-stt/src/artifacts.rs new file mode 100644 index 00000000..0d56aba5 --- /dev/null +++ b/crates/gateway-stt/src/artifacts.rs @@ -0,0 +1,294 @@ +//! 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; + +/// 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 }); + } + + 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), + 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, + + /// 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, + + /// 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 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/batch.rs b/crates/gateway-stt/src/batch.rs new file mode 100644 index 00000000..b91066e8 --- /dev/null +++ b/crates/gateway-stt/src/batch.rs @@ -0,0 +1,348 @@ +//! 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 + .engine() + .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..773e670c --- /dev/null +++ b/crates/gateway-stt/src/generation.rs @@ -0,0 +1,247 @@ +//! Atomic publication of one complete speech generation. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, PoisonError, RwLock, Weak}; + +use gateway_stt_backend_whisper::{WhisperConfig, WhisperModelFactory}; +use gateway_stt_engine::{DecodeMode, EnginePolicy, SttEngine}; + +use crate::artifacts::{PreparedSpeech, SpeechError}; +use crate::model::{ModelNames, SpeechModelInfo}; +use crate::status::SpeechStatus; + +#[derive(Debug, Clone, Copy)] +enum Backend { + Whisper, + #[cfg(feature = "test-fixtures")] + Scripted, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Admission { + Open, +} + +/// One staged generation token whose internals remain service-owned. +#[derive(Debug)] +pub struct SpeechReplacement { + owner: Weak, + generation: Option, +} + +#[derive(Debug)] +pub(crate) struct Generation { + id: u64, + backend: Backend, + engine: Arc, + names: ModelNames, + guidance: Arc<[String]>, + admission: Admission, +} + +impl Generation { + pub(crate) fn engine(&self) -> &SttEngine { + &self.engine + } + + pub(crate) fn engine_handle(&self) -> Arc { + Arc::clone(&self.engine) + } + + pub(crate) fn guidance(&self) -> &[String] { + &self.guidance + } + + 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) + } + + fn models(&self) -> Vec { + self.names.infos() + } + + fn select(&self, name: &str) -> Option { + (self.admission == Admission::Open) + .then(|| self.names.select(name)) + .flatten() + } +} + +#[derive(Debug)] +struct Shared { + active: RwLock>>, + next_generation: AtomicU64, + changes: tokio::sync::watch::Sender, +} + +/// 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 { + let (changes, _receiver) = tokio::sync::watch::channel(0); + Self { + shared: Arc::new(Shared { + active: RwLock::new(None), + next_generation: AtomicU64::new(1), + changes, + }), + } + } +} + +impl GenerationState { + pub(crate) fn stage(&self, prepared: PreparedSpeech) -> Result { + let generation = 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)?; + let engine = SttEngine::new(factory, policy).map_err(SpeechError::Engine)?; + Ok(Generation { + id: self.next_id(), + backend: Backend::Whisper, + engine: Arc::new(engine), + names: prepared.names, + guidance: prepared.guidance.into(), + admission: Admission::Open, + }) + }) + .transpose()?; + Ok(SpeechReplacement { + owner: Arc::downgrade(&self.shared), + generation, + }) + } + + #[cfg(feature = "test-fixtures")] + pub(crate) fn stage_scripted( + &self, + engine: SttEngine, + interim: String, + final_model: Option, + guidance: Vec, + ) -> SpeechReplacement { + SpeechReplacement { + owner: Arc::downgrade(&self.shared), + generation: Some(Generation { + id: self.next_id(), + backend: Backend::Scripted, + engine: Arc::new(engine), + names: ModelNames::new(interim, final_model), + guidance: guidance.into(), + admission: Admission::Open, + }), + } + } + + 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 published = replacement.generation.map(Arc::new); + let revision = published + .as_ref() + .map_or_else(|| self.next_id(), |generation| generation.id); + let mut active = self + .shared + .active + .write() + .unwrap_or_else(PoisonError::into_inner); + if active.is_some() { + return Err(SpeechError::GenerationActive); + } + *active = published; + drop(active); + self.shared.changes.send_replace(revision); + Ok(()) + } + + pub(crate) fn shutdown(&self) { + let generation = self + .shared + .active + .write() + .unwrap_or_else(PoisonError::into_inner) + .take(); + if generation.is_some() { + self.shared.changes.send_replace(self.next_id()); + } + unload(generation); + } + + pub(crate) fn active(&self) -> Option> { + self.shared + .active + .read() + .unwrap_or_else(PoisonError::into_inner) + .as_ref() + .filter(|generation| generation.admission == Admission::Open) + .cloned() + } + + pub(crate) fn select(&self, name: &str) -> Option<(Arc, DecodeMode)> { + let generation = self.active()?; + let mode = generation.select(name)?; + Some((generation, mode)) + } + + pub(crate) fn subscribe(&self) -> tokio::sync::watch::Receiver { + self.shared.changes.subscribe() + } + + pub(crate) fn status(&self) -> SpeechStatus { + self.active() + .as_deref() + .map_or_else(SpeechStatus::inactive, Generation::status) + } + + pub(crate) fn models(&self) -> Vec { + self.active() + .as_deref() + .map_or_else(Vec::new, Generation::models) + } + + fn next_id(&self) -> u64 { + self.shared.next_generation.fetch_add(1, Ordering::Relaxed) + } +} + +fn unload(generation: Option>) { + let Some(generation) = generation else { + return; + }; + let engine = Arc::clone(&generation.engine); + while Arc::strong_count(&generation) > 1 { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + drop(generation); + while Arc::strong_count(&engine) > 1 { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + drop(engine); +} diff --git a/crates/gateway-stt/src/lib.rs b/crates/gateway-stt/src/lib.rs index 224f9edb..c7b2d71c 100644 --- a/crates/gateway-stt/src/lib.rs +++ b/crates/gateway-stt/src/lib.rs @@ -1,19 +1,19 @@ -//! 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_stt_engine::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 the temporary legacy socket. -mod api; +mod artifacts; #[allow(dead_code)] mod audio; +mod batch; +mod generation; +mod model; #[allow(dead_code)] mod realtime; -mod runtime; mod segment; +mod service; +mod status; #[cfg(not(miri))] mod stt; mod take; @@ -22,8 +22,19 @@ 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 segment::Segmenter; -#[cfg(not(miri))] -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..af46f6c9 --- /dev/null +++ b/crates/gateway-stt/src/model.rs @@ -0,0 +1,55 @@ +//! Physical speech-model identity inside one active generation. + +use gateway_stt_engine::DecodeMode; + +/// One active physical 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 configured physical 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) -> Self { + Self { + interim, + final_model, + } + } + + 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(usize::from(self.final_model.is_some()) + 1); + models.push(SpeechModelInfo::new(self.interim.clone())); + if let Some(final_model) = &self.final_model { + models.push(SpeechModelInfo::new(final_model.clone())); + } + models + } +} diff --git a/crates/gateway-stt/src/runtime.rs b/crates/gateway-stt/src/runtime.rs deleted file mode 100644 index 7e9895a3..00000000 --- a/crates/gateway-stt/src/runtime.rs +++ /dev/null @@ -1,455 +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}; -use gateway_local::artifacts::ArtifactStore; -use gateway_stt_backend_whisper::{WhisperConfig, WhisperModelFactory}; -use gateway_stt_engine::{EnginePolicy, SttEngine}; -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, - guidance: Vec, -} - -#[derive(Debug, Clone, Default)] -struct SttSlot { - engine: Arc>>>, -} - -impl SttSlot { - fn engine(&self) -> Option> { - self.engine - .read() - .unwrap_or_else(PoisonError::into_inner) - .clone() - } - - fn is_active(&self) -> bool { - self.engine - .read() - .unwrap_or_else(PoisonError::into_inner) - .is_some() - } - - fn activate(&self, engine: SttEngine) { - *self.engine.write().unwrap_or_else(PoisonError::into_inner) = Some(Arc::new(engine)); - } - - fn take(&self) -> Option> { - self.engine - .write() - .unwrap_or_else(PoisonError::into_inner) - .take() - } -} - -/// 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, Vec)> { - let (role, guidance) = { - let names = self.names.read().unwrap_or_else(PoisonError::into_inner); - let role = if names.interim.as_deref() == Some(name) { - Some(LoadedModelRole::Interim) - } else if names.final_model.as_deref() == Some(name) { - Some(LoadedModelRole::Final) - } else { - None - }?; - (role, names.guidance.clone()) - }; - self.slot.engine().map(|engine| (engine, role, guidance)) - } - - pub(crate) fn subscribe(&self) -> tokio::sync::watch::Receiver { - self.changes.subscribe() - } - - pub(crate) fn guidance(&self) -> Vec { - self.names - .read() - .unwrap_or_else(PoisonError::into_inner) - .guidance - .clone() - } - - fn activate( - &self, - engine: SttEngine, - interim: String, - final_model: Option, - guidance: Vec, - ) { - self.slot.activate(engine); - *self.names.write().unwrap_or_else(PoisonError::into_inner) = LoadedNames { - interim: Some(interim), - final_model, - guidance, - }; - 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, - } - } - - #[cfg(feature = "test-fixtures")] - pub(crate) fn from_scripted_engine( - engine: SttEngine, - interim: String, - final_model: Option, - guidance: Vec, - ) -> SttRuntime { - let state = SttState::default(); - state.activate(engine, interim, final_model, guidance); - SttRuntime { - state, - active: true, - } - } - - /// 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.stt().cloned().unwrap_or_default(); - let guidance = capture.vocabulary().to_vec(); - let backend_config = WhisperConfig::new( - library, - interim_path, - models.final_model.as_ref().map(|(_, path)| path.clone()), - progress.map(|handle| handle.child("engine", 1.0)), - ); - let factory = WhisperModelFactory::new(backend_config).map_err(SttRuntimeError::Engine)?; - let policy = EnginePolicy::new( - capture.window_seconds(), - capture.interval_ms(), - factory.gpu_available(), - ) - .map_err(SttRuntimeError::Engine)?; - let engine = SttEngine::new(factory, policy).map_err(SttRuntimeError::Engine)?; - let final_name = models.final_model.map(|(name, _)| name); - state.activate(engine, interim_name, final_name, guidance); - 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) -} - -/// 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_stt_engine::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, - guidance: Vec::new(), - }; - 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 = crate::test_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-stt/src/segment.rs b/crates/gateway-stt/src/segment.rs index e53f9738..7293849b 100644 --- a/crates/gateway-stt/src/segment.rs +++ b/crates/gateway-stt/src/segment.rs @@ -32,7 +32,7 @@ const MIN_SPEECH_SAMPLES: usize = EnginePolicy::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,27 +47,27 @@ pub struct Segmenter { impl Segmenter { /// A fresh segmenter positioned at the start of a take buffer. #[must_use] - pub fn new() -> Self { + 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 = EnginePolicy::is_silence(frame); diff --git a/crates/gateway-stt/src/service.rs b/crates/gateway-stt/src/service.rs new file mode 100644 index 00000000..b8a24911 --- /dev/null +++ b/crates/gateway-stt/src/service.rs @@ -0,0 +1,104 @@ +//! 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; +use crate::status::SpeechStatus; + +/// Cloneable Gateway handle for all speech behavior. +#[derive(Debug, Clone, Default)] +pub struct SpeechService { + state: GenerationState, +} + +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) + } + + /// Loads a prepared worker generation without publishing it. + /// + /// # Errors + /// Returns a backend, policy, or worker startup error. + pub fn begin_replacement( + &self, + prepared: PreparedSpeech, + ) -> Result { + self.state.stage(prepared) + } + + /// Publishes every fact in a staged generation through one transition. + /// + /// # Errors + /// Returns an ownership error for a foreign token or when the caller did + /// not shut down the prior generation first. + pub fn commit_replacement(&self, replacement: SpeechReplacement) -> Result<(), SpeechError> { + self.state.commit(replacement) + } + + /// Drops a staged generation without publishing it. + pub fn abort_replacement(&self, replacement: SpeechReplacement) { + drop(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 models from one point-in-time generation snapshot. + #[must_use] + pub fn models(&self) -> Vec { + self.state.models() + } + + /// Returns the batch and temporary legacy Gateway routes. + #[cfg(not(miri))] + pub fn routes(&self) -> axum::Router { + crate::batch::routes(self.state.clone()) + .merge(crate::stt::gateway_router(self.state.clone())) + } + + /// Returns the temporary Workshop-hosted legacy routes. + #[cfg(not(miri))] + pub fn workshop_routes(&self, push: workshop_server::Push) -> axum::Router { + crate::stt::workshop_router(self.state.clone(), push) + } + + #[cfg(feature = "test-fixtures")] + pub(crate) fn scripted_replacement( + &self, + engine: gateway_stt_engine::SttEngine, + final_model: Option, + ) -> SpeechReplacement { + self.state.stage_scripted( + engine, + "scripted-interim".to_owned(), + final_model, + Vec::new(), + ) + } +} diff --git a/crates/gateway-stt/src/status.rs b/crates/gateway-stt/src/status.rs new file mode 100644 index 00000000..6581b3f7 --- /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 inactive() -> Self { + Self { + configured: false, + 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 index e10fd724..fd7998ef 100644 --- a/crates/gateway-stt/src/stt.rs +++ b/crates/gateway-stt/src/stt.rs @@ -15,7 +15,7 @@ use serde::Serialize; use tokio::sync::{mpsc, watch}; use workshop_server::{Activity, Push}; -use crate::runtime::SttState; +use crate::generation::{Generation, GenerationState}; use crate::take::Take; static NEXT_SESSION: AtomicU64 = AtomicU64::new(1); @@ -26,7 +26,7 @@ const WORKSHOP_STATUS_HEADER: &str = "x-promptforge-workshop-status"; #[derive(Debug, Clone)] struct RouteState { - stt: SttState, + speech: GenerationState, reporter: Reporter, } @@ -120,38 +120,26 @@ fn relay_status( } } -/// 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)) +pub(crate) fn workshop_router(speech: GenerationState, push: Push) -> Router { + routes_with_reporter(speech, 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) +pub(crate) fn gateway_router(speech: GenerationState) -> Router { + routes_with_reporter(speech, Reporter::Silent) } -fn routes_with_reporter(stt: SttState, reporter: Reporter) -> Router { +fn routes_with_reporter(speech: GenerationState, reporter: Reporter) -> Router { Router::new() .route("/stt/capability", get(capability)) .route("/stt", get(upgrade)) - .with_state(RouteState { stt, reporter }) + .with_state(RouteState { speech, 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(); + let status = state.speech.status(); + let gpu = status.gpu(); + let engine = status.ready(); ( [(header::CONTENT_TYPE, "application/json")], format!(r#"{{"gpu":{gpu},"engine":{engine}}}"#), @@ -177,7 +165,7 @@ async fn upgrade( } (reporter, _) => (reporter, None), }; - run_session(socket, state.stt, reporter, statuses) + run_session(socket, state.speech, reporter, statuses) }) } @@ -438,16 +426,17 @@ async fn stop_transcript( fn begin_take( session: u64, generation: u64, - engine: Option<&Arc>, - guidance: Vec, + active: Option<&Arc>, reporter: &Reporter, ) -> (Arc, Option) { - let state = Arc::new(Take::new(guidance, engine.cloned())); + let engine = active.map(|generation| generation.engine_handle()); + let guidance = active.map_or_else(Vec::new, |generation| generation.guidance().to_vec()); + let state = Arc::new(Take::new(guidance, engine.clone())); let active = engine.map(|engine| { spawn_interim( session, generation, - Arc::clone(engine), + engine, Arc::clone(&state), reporter.clone(), ) @@ -527,9 +516,13 @@ impl SessionAudio { } } +fn active_engine(generation: Option<&Arc>) -> Option<&SttEngine> { + generation.map(|generation| generation.engine()) +} + async fn run_session( mut socket: WebSocket, - stt: SttState, + speech: GenerationState, reporter: Reporter, mut statuses: Option>, ) { @@ -542,8 +535,8 @@ async fn run_session( let mut audio = SessionAudio::new(); let mut take: Option = None; - let mut engine = stt.engine(); - let mut engine_changes = stt.subscribe(); + let mut generation_state = speech.active(); + let mut engine_changes = speech.subscribe(); let mut generation = 0u64; loop { @@ -555,7 +548,7 @@ async fn run_session( } take = None; audio.take = Arc::new(Take::new(Vec::new(), None)); - engine = stt.engine(); + generation_state = speech.active(); } interim = next_interim(&mut take) => { if let Some(text) = interim @@ -573,7 +566,7 @@ async fn run_session( } inbound = socket.recv() => match inbound { Some(Ok(Message::Binary(payload))) => { - audio.receive(&payload, engine.as_deref(), &reporter); + audio.receive(&payload, active_engine(generation_state.as_ref()), &reporter); } Some(Ok(Message::Text(text))) => match text.as_str() { STT_START => { @@ -587,8 +580,7 @@ async fn run_session( let (next_take, active) = begin_take( session, generation, - engine.as_ref(), - stt.guidance(), + generation_state.as_ref(), &reporter, ); audio.take = next_take; @@ -603,7 +595,7 @@ async fn run_session( ); let text = stop_transcript( session, - engine.as_deref(), + active_engine(generation_state.as_ref()), &audio.take, &reporter, ) diff --git a/crates/gateway-stt/src/test_fixtures.rs b/crates/gateway-stt/src/test_fixtures.rs index 7e684b0f..880183ba 100644 --- a/crates/gateway-stt/src/test_fixtures.rs +++ b/crates/gateway-stt/src/test_fixtures.rs @@ -8,37 +8,48 @@ pub use gateway_stt_engine::DecodeMode; #[cfg(feature = "test-fixtures")] pub use gateway_stt_engine::test_fixtures::{ScriptedDecoder, ScriptedModelFactory}; -#[cfg(feature = "test-fixtures")] -use crate::SttRuntime; #[cfg(feature = "test-fixtures")] use crate::realtime::{CommitReceipt, InterimEpoch, ItemResult, Session, SessionRegistry}; #[cfg(feature = "test-fixtures")] -use gateway_stt_engine::{EnginePolicy, SttEngine, TranscribeError}; +use crate::{SpeechError, SpeechService}; +#[cfg(feature = "test-fixtures")] +use gateway_stt_engine::{EnginePolicy, SttEngine}; #[cfg(feature = "test-fixtures")] use std::future::Future; #[cfg(feature = "test-fixtures")] use std::sync::Arc; -/// Builds a speech runtime around deterministic scripted workers. +/// Builds a speech service around deterministic scripted workers. /// /// # Errors /// Returns engine policy, startup, or worker construction failures. #[cfg(feature = "test-fixtures")] -pub fn scripted_runtime( +pub fn scripted_service( factory: ScriptedModelFactory, window_seconds: u64, interval_ms: u64, -) -> Result { +) -> Result { let gpu_available = factory.gpu_available(); - let policy = EnginePolicy::new(window_seconds, interval_ms, gpu_available)?; - let engine = SttEngine::new(factory, policy)?; + let policy = EnginePolicy::new(window_seconds, interval_ms, gpu_available) + .map_err(SpeechError::Engine)?; + let engine = SttEngine::new(factory, policy).map_err(SpeechError::Engine)?; let final_name = engine.has_final_pass().then(|| "scripted-final".to_owned()); - Ok(SttRuntime::from_scripted_engine( - engine, - "scripted-interim".to_owned(), - final_name, - Vec::new(), - )) + let service = SpeechService::new(); + let replacement = service.scripted_replacement(engine, final_name); + service.commit_replacement(replacement)?; + Ok(service) +} + +/// Returns every closed speech range produced by the service segmenter. +#[cfg(feature = "test-fixtures")] +#[must_use] +pub fn segment_ranges(samples: &[f32]) -> Vec> { + let mut segmenter = crate::segment::Segmenter::new(); + let mut ranges = Vec::new(); + while let Some(range) = segmenter.poll(samples) { + ranges.push(range); + } + ranges } /// A deterministic registry for focused Realtime session integration tests. diff --git a/crates/gateway-stt/tests/common/mod.rs b/crates/gateway-stt/tests/common/mod.rs index 896628b9..ab0a0cf1 100644 --- a/crates/gateway-stt/tests/common/mod.rs +++ b/crates/gateway-stt/tests/common/mod.rs @@ -9,12 +9,9 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use axum::body::Body; -use axum::extract::{Multipart, State}; use axum::http::{Request, StatusCode}; -use axum::response::{IntoResponse as _, Response}; -use axum::routing::post; use futures_util::{SinkExt, StreamExt}; -use gateway_stt::{SttRuntime, SttState}; +use gateway_stt::SpeechService; use tokio::net::TcpStream; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; @@ -57,28 +54,28 @@ fn require_fixture(variable: &str, fallback: &str) -> PathBuf { path } -pub(crate) fn fixture_runtime(with_final: bool) -> (SttState, SttRuntime) { +pub(crate) fn fixture_service(with_final: bool) -> SpeechService { let source = require_model(); - fixture_runtime_with_models(&source, with_final.then_some(source.as_path())) + fixture_service_with_models(&source, with_final.then_some(source.as_path())) } -pub(crate) fn fixture_runtime_with_models( +pub(crate) fn fixture_service_with_models( interim_model: &Path, final_model: Option<&Path>, -) -> (SttState, SttRuntime) { +) -> SpeechService { let interim_model = interim_model.to_path_buf(); let final_model = final_model.map(Path::to_path_buf); std::thread::spawn(move || { - fixture_runtime_with_models_on_dedicated_thread(&interim_model, final_model.as_deref()) + fixture_service_with_models_on_dedicated_thread(&interim_model, final_model.as_deref()) }) .join() - .expect("fixture runtime startup thread succeeds") + .expect("fixture service startup thread succeeds") } -fn fixture_runtime_with_models_on_dedicated_thread( +fn fixture_service_with_models_on_dedicated_thread( interim_model: &Path, final_model: Option<&Path>, -) -> (SttState, SttRuntime) { +) -> 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('\\', "/")); @@ -107,9 +104,17 @@ fn fixture_runtime_with_models_on_dedicated_thread( 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"); - (state, runtime) + 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 } pub(crate) fn copy_model_replacing_token( @@ -142,22 +147,21 @@ pub(crate) fn copy_model_replacing_token( } pub(crate) fn fixture_server(with_final: bool) -> TestServer { - let (state, runtime) = fixture_runtime(with_final); - TestServer::spawn_with(state, Some(runtime)) + TestServer::spawn_with(fixture_service(with_final)) } pub(crate) struct TestServer { url: String, task: tokio::task::JoinHandle<()>, - runtime: Option, + service: SpeechService, } impl TestServer { pub(crate) fn spawn() -> Self { - Self::spawn_with(SttState::default(), None) + Self::spawn_with(SpeechService::new()) } - pub(crate) fn spawn_with(state: SttState, runtime: Option) -> Self { + pub(crate) fn spawn_with(service: SpeechService) -> Self { let std_listener = std::net::TcpListener::bind("127.0.0.1:0").expect("gateway listener binds"); std_listener @@ -168,7 +172,7 @@ impl TestServer { .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 app = service.routes(); let task = tokio::spawn(async move { axum::serve(listener, app) .await @@ -177,7 +181,7 @@ impl TestServer { Self { url: format!("http://{address}"), task, - runtime, + service, } } @@ -194,17 +198,16 @@ impl TestServer { let _ = tokio::time::timeout(SHUTDOWN_TIMEOUT, &mut self.task) .await .expect("gateway STT fixture server stops before the cleanup deadline"); - if let Some(runtime) = self.runtime.take() { - let (finished_tx, finished_rx) = tokio::sync::oneshot::channel(); - std::thread::spawn(move || { - runtime.shutdown(); - let _ = finished_tx.send(()); - }); - tokio::time::timeout(SHUTDOWN_TIMEOUT, finished_rx) - .await - .expect("fixture runtime stops before the cleanup deadline") - .expect("fixture runtime cleanup thread reports completion"); - } + let service = self.service.clone(); + let (finished_tx, finished_rx) = tokio::sync::oneshot::channel(); + std::thread::spawn(move || { + service.shutdown(); + let _ = finished_tx.send(()); + }); + tokio::time::timeout(SHUTDOWN_TIMEOUT, finished_rx) + .await + .expect("fixture service stops before the cleanup deadline") + .expect("fixture service cleanup thread reports completion"); } } @@ -277,25 +280,14 @@ fn multipart_body(file: &[u8], model: &str) -> (String, Vec) { (BOUNDARY.to_owned(), body) } -async fn batch_endpoint(State(state): State, multipart: Multipart) -> Response { - match gateway_stt::transcribe(&state, multipart).await { - Ok(response) => 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(), - } -} - pub(crate) async fn transcribe_batch( - state: SttState, + service: SpeechService, model: &str, samples: &[f32], ) -> (StatusCode, serde_json::Value) { let (boundary, body) = multipart_body(&wav_f32(samples), model); - let response = axum::Router::new() - .route("/v1/audio/transcriptions", post(batch_endpoint)) - .with_state(state) + let response = service + .routes() .oneshot( Request::builder() .method("POST") diff --git a/crates/gateway-stt/tests/common/native_runtime.rs b/crates/gateway-stt/tests/common/native_runtime.rs index 89f58b9d..9b532720 100644 --- a/crates/gateway-stt/tests/common/native_runtime.rs +++ b/crates/gateway-stt/tests/common/native_runtime.rs @@ -3,25 +3,33 @@ use std::time::Duration; -use crate::{SttRuntime, SttState}; +use crate::SpeechService; -pub(crate) fn start(config: gateway_config::Config, state: SttState) -> SttRuntime { +pub(crate) fn start(config: gateway_config::Config) -> SpeechService { let (result_tx, result_rx) = std::sync::mpsc::channel(); let startup = std::thread::spawn(move || { - drop(result_tx.send(SttRuntime::start(&config, state, None))); + 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 runtime = result_rx + 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"); - runtime + service } -pub(crate) fn shutdown(runtime: SttRuntime) { +pub(crate) fn shutdown(service: SpeechService) { let (finished_tx, finished_rx) = std::sync::mpsc::channel(); let shutdown = std::thread::spawn(move || { - runtime.shutdown(); + service.shutdown(); let _ = finished_tx.send(()); }); finished_rx diff --git a/crates/gateway-stt/tests/it/architecture.rs b/crates/gateway-stt/tests/it/architecture.rs index 92ecad67..0dfb0cd9 100644 --- a/crates/gateway-stt/tests/it/architecture.rs +++ b/crates/gateway-stt/tests/it/architecture.rs @@ -13,6 +13,13 @@ const STT_CRATES: [&str; 4] = [ "gateway-whisper-ffi", ]; +const PUBLIC_ROOT_BUDGETS: [(&str, usize); 4] = [ + ("gateway-stt", 6), + ("gateway-stt-engine", 7), + ("gateway-stt-backend-whisper", 2), + ("gateway-whisper-ffi", 6), +]; + const PHASE_A_CRATES: [&str; 7] = [ "gateway", "gateway-stt", @@ -121,23 +128,11 @@ const DEPENDENCY_POLICIES: [DependencyPolicy; 7] = [ const MIGRATION_POLICIES: [MigrationPolicy; 4] = [ MigrationPolicy { crate_name: "gateway-stt", - targets: &[ - MigrationPolicyTarget { - module: "api.rs", - target_step: "Step 18", - destination: "batch.rs", - }, - MigrationPolicyTarget { - module: "runtime.rs", - target_step: "Step 18", - destination: "service.rs, artifacts.rs, generation.rs, status.rs, and model.rs", - }, - MigrationPolicyTarget { - module: "stt.rs", - target_step: "Step 29", - destination: "removal after the Realtime route and Workshop relay replace the legacy socket", - }, - ], + targets: &[MigrationPolicyTarget { + module: "stt.rs", + target_step: "Step 29", + destination: "removal after the Realtime route and Workshop relay replace the legacy socket", + }], }, MigrationPolicy { crate_name: "gateway-stt-engine", @@ -425,6 +420,13 @@ fn expected_migration_targets(crate_name: &str) -> BTreeMap usize { + PUBLIC_ROOT_BUDGETS + .iter() + .find_map(|(name, budget)| (*name == crate_name).then_some(*budget)) + .unwrap_or_else(|| panic!("public-root policy must cover {crate_name}")) +} + fn validate_migration_targets(crate_name: &str, config: &CeilingsFile) -> Result<(), String> { let expected = expected_migration_targets(crate_name); if config.migration_targets != expected { @@ -448,9 +450,10 @@ fn module_ceilings_cover_sources_and_name_migration_targets() { for crate_name in STT_CRATES { let src = crate_root(crate_name).join("src"); let config = ceilings(crate_name); - assert!( - config.public_root_budget > 0, - "{crate_name} public root budget must be a strict positive ceiling" + assert_eq!( + config.public_root_budget, + expected_public_root_budget(crate_name), + "{crate_name} public root budget drifted from the exact phase policy" ); let measured = rust_sources(&src) .into_iter() @@ -502,23 +505,11 @@ fn misspelled_migration_section_is_rejected() { } #[test] -fn gateway_step_15_migrations_are_pinned_to_their_destinations() { +fn completed_step_18_migrations_are_removed() { let expected = expected_migration_targets("gateway-stt"); - assert_eq!( - expected["api.rs"], - MigrationTarget { - target_step: "Step 18".to_owned(), - destination: "batch.rs".to_owned(), - } - ); - assert_eq!( - expected["runtime.rs"], - MigrationTarget { - target_step: "Step 18".to_owned(), - destination: "service.rs, artifacts.rs, generation.rs, status.rs, and model.rs" - .to_owned(), - } - ); + assert!(!expected.contains_key("api.rs")); + assert!(!expected.contains_key("runtime.rs")); + assert_eq!(expected.keys().collect::>(), ["stt.rs"]); } #[test] diff --git a/crates/gateway-stt/tests/it/batch.rs b/crates/gateway-stt/tests/it/batch.rs index d159ab0a..dcbc405c 100644 --- a/crates/gateway-stt/tests/it/batch.rs +++ b/crates/gateway-stt/tests/it/batch.rs @@ -3,7 +3,7 @@ use axum::http::StatusCode; use crate::common::{ - copy_model_replacing_token, fixture_runtime_with_models, jfk_samples, require_model, + copy_model_replacing_token, fixture_service_with_models, jfk_samples, require_model, transcribe_batch, }; @@ -14,11 +14,11 @@ async fn batch_selects_each_loaded_physical_model_by_name() { 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 (state, runtime) = fixture_runtime_with_models(&interim_model, Some(final_model.as_path())); + let service = fixture_service_with_models(&interim_model, Some(final_model.as_path())); let samples = jfk_samples(); let (interim_status, interim_response) = - transcribe_batch(state.clone(), "speech", &samples).await; + transcribe_batch(service.clone(), "speech", &samples).await; assert_eq!( interim_status, StatusCode::OK, @@ -34,7 +34,7 @@ async fn batch_selects_each_loaded_physical_model_by_name() { ); let (final_status, final_response) = - transcribe_batch(state.clone(), "speech-final", &samples).await; + transcribe_batch(service.clone(), "speech-final", &samples).await; assert_eq!( final_status, StatusCode::OK, @@ -49,5 +49,5 @@ async fn batch_selects_each_loaded_physical_model_by_name() { "speech-final reaches the vocabulary-distinguished final worker: {final_text:?}" ); - runtime.shutdown(); + service.shutdown(); } diff --git a/crates/gateway-stt/tests/it/legacy_stream.rs b/crates/gateway-stt/tests/it/legacy_stream.rs index 716b2918..66455637 100644 --- a/crates/gateway-stt/tests/it/legacy_stream.rs +++ b/crates/gateway-stt/tests/it/legacy_stream.rs @@ -9,16 +9,16 @@ use std::time::Duration; use futures_util::{SinkExt as _, StreamExt as _}; -use gateway_stt::Segmenter; +use gateway_stt::test_fixtures::segment_ranges; use gateway_stt_engine::EnginePolicy; use serde_json::json; use tokio_tungstenite::tungstenite; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use crate::common::{ - JsonSocket, TestServer, copy_model_replacing_token, fixture_runtime, - fixture_runtime_with_models, fixture_server, jfk_samples, require_model, send_pcm, - send_samples, send_samples_once, transcribe_batch, + JsonSocket, TestServer, copy_model_replacing_token, fixture_server, fixture_service, + fixture_service_with_models, jfk_samples, require_model, send_pcm, send_samples, + send_samples_once, transcribe_batch, }; #[test] @@ -73,22 +73,18 @@ async fn closed_segments_are_reported_in_input_order() { samples.extend(vec![0.0; 3 * EnginePolicy::SAMPLE_RATE]); samples.extend_from_slice(&speech[2 * third..]); samples.extend(vec![0.0; 3 * EnginePolicy::SAMPLE_RATE]); - let mut segmenter = Segmenter::new(); - let mut ranges = Vec::new(); - while let Some(range) = segmenter.poll(&samples) { - ranges.push(range); - } + let ranges = segment_ranges(&samples); assert_eq!( ranges.len(), 2, "the native fixture halves form two closed speech segments" ); - let (state, runtime) = fixture_runtime(true); + let service = fixture_service(true); let (first_status, first_response) = - transcribe_batch(state.clone(), "speech-final", &samples[ranges[0].clone()]).await; + transcribe_batch(service.clone(), "speech-final", &samples[ranges[0].clone()]).await; let (second_status, second_response) = - transcribe_batch(state.clone(), "speech-final", &samples[ranges[1].clone()]).await; + transcribe_batch(service.clone(), "speech-final", &samples[ranges[1].clone()]).await; assert_eq!(first_status, axum::http::StatusCode::OK); assert_eq!(second_status, axum::http::StatusCode::OK); let first = first_response["text"] @@ -100,7 +96,7 @@ async fn closed_segments_are_reported_in_input_order() { let first_marker = distinguishing_word(first, second); let second_marker = distinguishing_word(second, first); - let server = TestServer::spawn_with(state, Some(runtime)); + let server = TestServer::spawn_with(service); let mut socket = JsonSocket::connect(&server.ws_url("/stt")).await; socket.send_text("start").await; assert_eq!(socket.recv_json().await["type"], "stream"); @@ -395,8 +391,8 @@ async fn final_model_segments_and_tail_are_authoritative_at_stop() { 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 (state, runtime) = fixture_runtime_with_models(&interim_model, Some(final_model.as_path())); - let server = TestServer::spawn_with(state, Some(runtime)); + let service = fixture_service_with_models(&interim_model, Some(final_model.as_path())); + let server = TestServer::spawn_with(service); let mut socket = JsonSocket::connect(&server.ws_url("/stt")).await; socket.send_text("start").await; assert_eq!(socket.recv_json().await["type"], "stream"); diff --git a/crates/gateway-stt/tests/it/main.rs b/crates/gateway-stt/tests/it/main.rs index 6d663e33..11e412c3 100644 --- a/crates/gateway-stt/tests/it/main.rs +++ b/crates/gateway-stt/tests/it/main.rs @@ -14,3 +14,5 @@ mod legacy_stream; mod realtime_fixtures; #[cfg(not(miri))] mod realtime_session; +#[cfg(not(miri))] +mod service; diff --git a/crates/gateway-stt/tests/it/service.rs b/crates/gateway-stt/tests/it/service.rs new file mode 100644 index 00000000..27f3f4fe --- /dev/null +++ b/crates/gateway-stt/tests/it/service.rs @@ -0,0 +1,67 @@ +//! Public speech-facade integration tests. + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use gateway_stt::SpeechService; +use gateway_stt::test_fixtures::{ScriptedDecoder, ScriptedModelFactory, scripted_service}; +use tower::ServiceExt as _; + +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"] + ); + + service.shutdown(); + assert!(!clone.status().ready()); + assert!(clone.models().is_empty()); +} + +#[tokio::test] +async fn facade_routes_keep_the_temporary_legacy_capability() { + let response = SpeechService::new() + .routes() + .oneshot( + Request::builder() + .uri("/stt/capability") + .body(Body::empty()) + .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"); + assert_eq!(&body[..], br#"{"gpu":false,"engine":false}"#); +} + +#[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/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 dfa31ad8..02258ba7 100644 --- a/crates/gateway/src/lib.rs +++ b/crates/gateway/src/lib.rs @@ -132,8 +132,6 @@ 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; use axum::http::header::{AUTHORIZATION, CACHE_CONTROL, CONTENT_TYPE}; @@ -158,7 +156,7 @@ use gateway_config::ModelKind; #[cfg(feature = "web-search")] use gateway_config::WebSearchConfig; #[cfg(feature = "stt")] -use gateway_stt::{SttRuntime, SttState}; +use gateway_stt::{SpeechReplacement, SpeechService}; #[cfg(feature = "web-search")] use gateway_web_search::{WebSearchRequest, WebSearchResponse, WebSearchState}; use shared_progress::{EventState, OperationId, ProgressEvent, ProgressHub, ProgressTree}; @@ -180,8 +178,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>, @@ -278,10 +274,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 @@ -368,7 +363,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, @@ -377,8 +372,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, @@ -389,8 +382,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(), @@ -416,7 +407,7 @@ impl AppState { salt }, #[cfg(feature = "stt")] - stt_state, + speech, #[cfg(test)] park: None, } @@ -479,14 +470,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")] @@ -564,12 +547,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 { @@ -615,40 +601,21 @@ 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()))?; 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) -} - /// Header naming the caller for fair queue scheduling. Absent → `"default"`. const CLIENT_HEADER: &str = "X-PromptForge-Client"; @@ -1082,7 +1049,7 @@ async fn admin_status( "/v1/audio/transcriptions", "Audio transcriptions", !live.config.stt_models().is_empty(), - state.stt_state.is_active(), + state.speech.status().ready(), command_active, )); endpoints @@ -1306,7 +1273,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": @@ -1381,7 +1348,8 @@ pub(crate) enum StatePersistence { /// the `ProvisionModel` command uses. Cancellation lands at chunk /// boundaries. /// 3. **Cut over** (locked, bounded): the bounded drain, then the old -/// local and STT runtimes stop under a `stopping-models` leaf (only +/// local runtimes and the active speech generation 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 @@ -1395,7 +1363,7 @@ pub(crate) enum StatePersistence { /// 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 @@ -1500,7 +1468,7 @@ async fn run_switch_phases( let replacement = spawn_runtimes( &target.config, #[cfg(feature = "stt")] - state.stt_state.clone(), + state.speech.clone(), tree, token, ) @@ -1618,11 +1586,11 @@ async fn stop_set(state: &AppState) -> StopSet { #[cfg(feature = "local")] local: live.local.child_count() > 0, #[cfg(feature = "stt")] - stt: state.stt_state.is_active(), + stt: state.speech.status().ready(), } } -/// A headless build runs no local or STT runtime, so there is never +/// A headless build runs no local runtime or speech service, so there is never /// anything to stop. #[cfg(not(any(feature = "local", feature = "stt")))] async fn stop_set(_state: &AppState) -> StopSet { @@ -1742,7 +1710,7 @@ async fn cut_over( #[cfg(feature = "local")] local: std::mem::replace(&mut live.local, LocalRuntime::empty()), #[cfg(feature = "stt")] - stt: live.stt.take(), + speech: stop.stt.then(|| state.speech.clone()), }) } }; @@ -1774,7 +1742,7 @@ struct OldRuntimes { #[cfg(feature = "local")] local: LocalRuntime, #[cfg(feature = "stt")] - stt: Option, + speech: Option, } impl OldRuntimes { @@ -1782,8 +1750,8 @@ impl OldRuntimes { /// 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(); + if let Some(speech) = self.speech { + speech.shutdown(); } #[cfg(feature = "local")] let result = self.local.shutdown(); @@ -1829,6 +1797,11 @@ async fn commit_switch( #[cfg(not(any(feature = "local", feature = "stt")))] let RuntimeReplacement {} = replacement; commit_profile_state(state, name, persistence, token).await?; + #[cfg(feature = "stt")] + state + .speech + .commit_replacement(replacement.speech) + .map_err(|error| GatewayError::switch_failed("publish-stt", error))?; #[cfg(feature = "local")] let report = StartReport { @@ -1862,10 +1835,6 @@ async fn commit_switch( { 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(); @@ -1939,10 +1908,10 @@ struct RuntimeReplacement { #[cfg(feature = "local")] start_failures: Vec, #[cfg(feature = "stt")] - stt: SttRuntime, + speech: SpeechReplacement, } -/// Phase 4 in a headless build: no local or STT runtime exists to start, +/// Phase 4 in a headless build: no local runtime or speech generation exists, /// and no `starting-models` leaf is registered. #[cfg(not(any(feature = "local", feature = "stt")))] async fn spawn_runtimes( @@ -1953,14 +1922,14 @@ async fn spawn_runtimes( Ok(RuntimeReplacement {}) } -/// Phase 4: starts the target's local children and STT engine and waits +/// Phase 4: starts the target's local children and staged speech generation 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, + #[cfg(feature = "stt")] speech: SpeechService, tree: &ProgressTree, token: &tokio_util::sync::CancellationToken, ) -> Result { @@ -2008,7 +1977,7 @@ async fn spawn_runtimes( }; #[cfg(feature = "local")] let (runtime, failures) = outcome.into_parts(); - // Phase boundary: a cancelled command starts no STT runtime behind the + // Phase boundary: a cancelled command starts no speech generation behind the // cancellation; the local runtime built above drops, killing its // children. #[cfg(feature = "stt")] @@ -2016,12 +1985,13 @@ async fn spawn_runtimes( return Err(GatewayError::CommandCancelled("profile switch".to_owned())); } #[cfg(feature = "stt")] - let stt_config = config.clone(); + let speech_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)) + let speech = match tokio::task::spawn_blocking(move || { + let prepared = speech.prepare(&speech_config, Some(&stt_progress))?; + speech.begin_replacement(prepared) }) .await { @@ -2049,7 +2019,7 @@ async fn spawn_runtimes( #[cfg(feature = "local")] start_failures: failures, #[cfg(feature = "stt")] - stt, + speech, }) } @@ -2380,6 +2350,75 @@ mod transcription_auth_tests { ); } + #[tokio::test] + async fn authenticated_multipart_rejection_uses_the_openai_error_envelope() { + let response = build_router(state(), None) + .oneshot( + Request::builder() + .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!(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() + .method("POST") + .uri("/v1/audio/transcriptions") + .header("authorization", "Bearer test-token") + .header("content-type", "multipart/form-data; boundary=empty") + .body(Body::from(body)) + .expect("request builds"), + ) + .await + .expect("router answers"); + + 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: missing multipart field model", + "type": "invalid_request_error", + "code": "malformed_request", + } + }) + ); + } + #[tokio::test] async fn stt_capability_is_mounted_behind_bearer_auth() { let unauthorized = build_router(state(), None) diff --git a/crates/gateway/src/runner.rs b/crates/gateway/src/runner.rs index d269a2bb..8cc9a371 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, diff --git a/crates/gateway/src/test_support.rs b/crates/gateway/src/test_support.rs index 5881897e..f1a3cc36 100644 --- a/crates/gateway/src/test_support.rs +++ b/crates/gateway/src/test_support.rs @@ -64,16 +64,14 @@ pub(crate) fn app_state(config: Config, paths: Option) -> AppState { /// Builds state with deterministic speech workers for Gateway route tests. #[cfg(feature = "stt")] -pub(crate) async fn app_state_with_scripted_stt( +pub(crate) fn app_state_with_scripted_stt( config: Config, factory: gateway_stt::test_fixtures::ScriptedModelFactory, ) -> Result { - let runtime = gateway_stt::test_fixtures::scripted_runtime(factory, 15, 500) + let service = gateway_stt::test_fixtures::scripted_service(factory, 15, 500) .map_err(|error| error.to_string())?; - let stt_state = runtime.state(); let mut state = app_state(config, None); - state.live.write().await.stt = Some(runtime); - state.stt_state = stt_state; + state.speech = service; Ok(state) } @@ -105,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, @@ -173,7 +171,6 @@ mod tests { let decoder = ScriptedDecoder::new(); decoder.push_text(TRANSCRIPT); let state = app_state_with_scripted_stt(config, ScriptedModelFactory::new(decoder.clone())) - .await .expect("scripted state builds"); let (boundary, body) = transcription_body(); @@ -209,4 +206,51 @@ mod tests { 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", + } + }) + ); + } } diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 95530c53..d883d358 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -535,7 +535,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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`. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index f3060f27..5808156b 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -130,8 +130,8 @@ N1 | observation | Violates A2 @ crates/gateway-stt/tests/fixtures/realtime: not N2 | observation | Violates A96 @ crates/workshop-server/ui/test/realtime-wire-fixtures.mjs: not determinable from diff | Freeze the realtime transcription wire contract 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 -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 +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 +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 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 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 @@ -149,9 +149,9 @@ N20 | observation | surface-growth @ crates/gateway-stt-engine/src/lib.rs::test_ 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 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 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 -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 -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 +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 +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 +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 @@ -160,3 +160,6 @@ N31 | observation | global-state @ crates/gateway-stt/src/realtime/wire/shared.r 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 +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 +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 +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 From c9cbe637e018044f2051ed42e39680461958ac84 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 11:55:04 -0700 Subject: [PATCH 27/86] Resolve Windows cache ownership by SID Resolve the current Windows process identity as a canonical SID before restricting artifact cache access. This keeps private cache setup valid for interactive users and service accounts while failing closed on identity or ACL errors. - `current_windows_sid` replaces profile environment names with one quoted CSV identity record from `whoami` and gives the validated SID to the ACL grant path. - `target_step` and `removal_step` advance the legacy socket removal target by one execution position so the architecture ratchets preserve the same migration boundary. - `parse_whoami_user_sid` delegates shape checks to `is_canonical_windows_sid`, accepts ordinary and service identities, rejects command failures and malformed or noncanonical output, and returns command error detail. - `windows_sid_grant` renders the required SID principal prefix, while `artifact_store_enforces_private_windows_dacl` verifies that the current process retains write access after restriction. Design: replaces hidden-dependency @ crates/gateway-local/src/artifacts/confine.rs::current_windows_sid deps: &Path was: crates/gateway-local/src/artifacts/confine.rs::current_windows_account Design: new stringly-typed @ crates/gateway-local/src/artifacts/confine.rs Design: new flag-parameter @ crates/gateway-local/src/artifacts/confine.rs::parse_whoami_user_sid deps: &[u8],&[u8],bool Design: new pure-function @ crates/gateway-local/src/artifacts/confine.rs::parse_whoami_user_sid deps: &[u8],&[u8],bool Design: new pure-function @ crates/gateway-local/src/artifacts/confine.rs::is_canonical_windows_sid deps: &str Design: new pure-function @ crates/gateway-local/src/artifacts/confine.rs::windows_sid_grant deps: &str Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- crates/gateway-local/src/artifacts/confine.rs | 148 +++++++++++++++--- crates/gateway-local/src/artifacts/tests.rs | 73 +++++++++ crates/gateway-stt/module-ceilings.toml | 2 +- crates/gateway-stt/tests/it/architecture.rs | 4 +- vibe/2026-09-05-2-generic-realtime-stt.md | 73 +++++---- 5 files changed, 244 insertions(+), 56 deletions(-) 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-stt/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index 5ce4850d..dd8a10f6 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -5,7 +5,7 @@ public_root_budget = 6 [migration_targets."stt.rs"] -target_step = "Step 29" +target_step = "Step 30" destination = "removal after the Realtime route and Workshop relay replace the legacy socket" [modules] diff --git a/crates/gateway-stt/tests/it/architecture.rs b/crates/gateway-stt/tests/it/architecture.rs index 0dfb0cd9..3230f2c6 100644 --- a/crates/gateway-stt/tests/it/architecture.rs +++ b/crates/gateway-stt/tests/it/architecture.rs @@ -82,7 +82,7 @@ const DEPENDENCY_POLICIES: [DependencyPolicy; 7] = [ ], temporary_edges: &[TemporaryEdge { dependency: "workshop-server", - removal_step: "Step 29", + removal_step: "Step 30", }], }, DependencyPolicy { @@ -130,7 +130,7 @@ const MIGRATION_POLICIES: [MigrationPolicy; 4] = [ crate_name: "gateway-stt", targets: &[MigrationPolicyTarget { module: "stt.rs", - target_step: "Step 29", + target_step: "Step 30", destination: "removal after the Realtime route and Workshop relay replace the legacy socket", }], }, diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index d883d358..de748781 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -32,6 +32,9 @@ todos: - 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 isProject: false --- @@ -56,7 +59,7 @@ isProject: false - 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 31 are the sole logging exception. + - 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 32 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. @@ -234,6 +237,7 @@ isProject: false - 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. - 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. @@ -346,7 +350,7 @@ isProject: false 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 23: `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 24 through 28, adds only `workshop-server -> shared-loopback`. Final Phase C, Step 29 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. +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 25 through 29, adds only `workshop-server -> shared-loopback`. Final Phase C, Step 30 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] @@ -547,7 +551,18 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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: Quiesce generations with explicit ownership +### 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 - 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. @@ -558,7 +573,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 20: Make profile replacement transactional +### Step 21: Make profile replacement transactional - 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. @@ -567,9 +582,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 19; cancellation-at-every-await and rollback outcomes gate route mounting. +- Consumes and gates: consumes Step 20; cancellation-at-every-await and rollback outcomes gate route mounting. -### Step 21: Separate origin predicates +### Step 22: Separate origin predicates - 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. @@ -577,7 +592,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 22: Integrate generic speech facts +### Step 23: Integrate generic speech facts - 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. @@ -587,9 +602,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 20 lifecycle; status correctness gates route publication. +- Consumes and gates: consumes Step 18 facade and Step 21 lifecycle; status correctness gates route publication. -### Step 23: Mount the additive Gateway route +### Step 24: Mount the additive Gateway route - 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. @@ -597,36 +612,36 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 22; the independent Gateway fixture path gates Workshop relay work. +- Consumes and gates: consumes Steps 12 through 23; the independent Gateway fixture path gates Workshop relay work. -### Step 24: Add the Workshop relay beside legacy +### Step 25: Add the Workshop relay beside legacy - 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 21 Workshop predicate and Step 23 public fixtures, but adds no dependency on Gateway or gateway-stt. +- Consumes and gates: consumes Step 22 Workshop predicate and Step 24 public fixtures, but adds no dependency on Gateway or gateway-stt. -### Step 25: Prove the actual worklet bytes +### Step 26: Prove the actual worklet bytes - 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 24 additive relay; byte parity gates browser migration. +- Consumes and gates: consumes Step 11 language-neutral bytes and Step 25 additive relay; byte parity gates browser migration. -### Step 26: Migrate Workshop browser speech +### Step 27: Migrate Workshop browser speech - 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, 24, and 25; browser acceptance gates independent full-path automation. +- Consumes and gates: consumes Steps 3, 25, and 26; browser acceptance gates independent full-path automation. -### Step 27: Prove both fixture-driven halves +### Step 28: Prove both fixture-driven halves - 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. @@ -634,9 +649,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 23 through 26; both independent halves must pass before packaging. +- Consumes and gates: consumes Steps 24 through 27; both independent halves must pass before packaging. -### Step 28: Pass installed Windows microphone acceptance +### Step 29: Pass installed Windows microphone acceptance - 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` steps `Build and stage the gateway sidecar`, `Build the app`, and `Install and check (Windows)`, then record installed-package microphone revision, completion, second take, clear, cancellation, and recoverable permission or device failure with binary hashes and timestamps. @@ -647,12 +662,12 @@ The architecture harness enforces exact workspace-package edges across normal, d - `C:\Users\Vinnie\cursor\promptforge\crates\workshop`: `if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY)) { throw 'TAURI_SIGNING_PRIVATE_KEY is required by the release workflow' }; if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD)) { throw 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD is required by the release workflow' }; $env:RUSTUP_TOOLCHAIN='stable'; cargo tauri build --bundles nsis` - `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 27; the operator must exercise the installed application and record passing evidence, which alone gates legacy removal. +- Consumes and gates: consumes Step 28; the operator must exercise the installed application and record passing evidence, which alone gates legacy removal. -### Step 29: Remove legacy seams and tests +### Step 30: Remove legacy seams and tests - 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 Step 3, 23, 24, 26, or 27 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. +- Scope: map every retired legacy assertion to Steps 3, 24, 25, 27, and 28 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` @@ -663,7 +678,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 30: Finalize architecture and documentation +### Step 31: Finalize architecture and documentation - 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. @@ -673,9 +688,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 29 final topology; final verification starts only with zero temporary exceptions. +- Consumes and gates: consumes Step 30 final topology; final verification starts only with zero temporary exceptions. -### Step 31: Bookend Gateway serving logs +### Step 32: Bookend Gateway serving logs - 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. @@ -683,9 +698,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 30 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 32's full release verification must pass after this change. +- Consumes and gates: this operator-requested observability correction is independent of STT and follows Step 31 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 33's full release verification must pass after this change. -### Step 32: Run every release gate and repeat acceptance +### Step 33: Run every release gate and repeat acceptance - Artifacts: append command results, hashes, ratchet counts, native equivalence, generated-doc cleanliness, and repeated installed-microphone evidence to `design/generic-realtime-stt-acceptance.md`; change no implementation. - Scope: run every exit criterion independently under PowerShell 5.1 and stop on any failure. @@ -719,6 +734,6 @@ The architecture harness enforces exact workspace-package edges across normal, d - `C:\Users\Vinnie\cursor\promptforge\crates\workshop`: `if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY)) { throw 'TAURI_SIGNING_PRIVATE_KEY is required by the release workflow' }; if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD)) { throw 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD is required by the release workflow' }; $env:RUSTUP_TOOLCHAIN='stable'; cargo tauri build --bundles nsis` - `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 31, then repeats the Step 28 installed-package microphone scenarios. Completion requires every command, ratchet, generated-doc check, and physical scenario to pass with no open finding. +- Consumes and gates: consumes Step 32, then repeats the Step 29 installed-package microphone scenarios. 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 31's exact two records, or completed Workshop baseline-ratchet work. VAD tuning, alignment, 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 +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 32's exact two records, or completed Workshop baseline-ratchet work. VAD tuning, alignment, 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 From 15c210dfa05d3ec622b188e922f6057377ed7c18 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 12:31:33 -0700 Subject: [PATCH 28/86] Quiesce speech generations before replacement Serialize speech generation replacement and close admission while old requests and worker jobs drain. Explicit ownership counters and cancellation epochs prevent canceled requests from hiding live native work, while deadlines reopen the old snapshot and shutdown invalidates staged publication. Exact module ceilings and focused ownership tests enforce the lifecycle. - `ReplacementCoordinator` owns one replacement lane, while `AdmissionGate` counts request and worker ownership under a fresh `SessionEpoch`. - `GenerationLease` keeps each request attached to one complete snapshot, and `GenerationJob` keeps native work owned after request cancellation. - `GenerationState` removes the active snapshot only after bounded drain, shuts down its workers, and builds the unpublished replacement afterward. - `SttEngine::shutdown` now uses shared access and serializes worker cleanup inside `Transcriber`. - `validate_module_ceiling` requires every manifest ceiling to equal the measured file size and rejects settled Gateway STT modules above 500 lines. - `SpeechError` distinguishes a drain deadline from replacement invalidation by shutdown. Design: extends surface-growth @ crates/gateway-stt-engine/src/engine.rs::SttEngine::shutdown boundary: pub Design: extends shared-mutable-state @ crates/gateway-stt-engine/src/worker.rs::Transcriber Design: new surface-growth @ crates/gateway-stt/src/artifacts.rs::SpeechError boundary: pub Design: extends shared-mutable-state @ crates/gateway-stt/src/generation.rs::GenerationState Design: extends encapsulated-invariant @ crates/gateway-stt/src/generation.rs::SpeechReplacement boundary: pub Design: removes hidden-dependency @ crates/gateway-stt/src/generation.rs::unload deps: Option Design: replaces parameter-object @ crates/gateway-stt/src/generation/snapshot.rs::Generation was: crates/gateway-stt/src/generation.rs::Generation Design: new shared-parameter-cluster @ crates/gateway-stt/src/generation/snapshot.rs::Generation::from_factory Design: new shared-mutable-state @ crates/gateway-stt/src/replacement.rs::ReplacementCoordinator Design: new shared-mutable-state @ crates/gateway-stt/src/replacement.rs::SessionEpoch Design: new shared-mutable-state @ crates/gateway-stt/src/replacement.rs::AdmissionGate Design: new oversized-unit @ crates/gateway-stt/src/replacement.rs Design: extends facade @ crates/gateway-stt/src/service.rs::SpeechService boundary: pub Design: extends temporal-coupling @ crates/gateway-stt/src/service.rs::SpeechService::commit_replacement Design: replaces constructor-injection @ crates/gateway-stt/src/test_fixtures/generation.rs::scripted_service deps: ScriptedModelFactory,u64,u64 boundary: pub was: crates/gateway-stt/src/test_fixtures.rs::scripted_service Design: replaces pure-function @ crates/gateway-stt/src/test_fixtures/segment.rs::segment_ranges deps: &[f32] boundary: pub was: crates/gateway-stt/src/test_fixtures.rs::segment_ranges Design: replaces surface-growth @ crates/gateway-stt/src/test_fixtures/segment.rs::segment_ranges boundary: pub was: crates/gateway-stt/src/test_fixtures.rs::segment_ranges Design: replaces clone-block @ crates/gateway-stt/src/test_fixtures/native.rs was: crates/gateway-stt/src/test_fixtures.rs Design: extends surface-growth @ crates/gateway-stt/src/test_fixtures.rs boundary: pub Design: extends facade @ crates/gateway-stt/src/test_fixtures.rs boundary: pub Design: new pure-function @ crates/gateway-stt/tests/it/architecture.rs::validate_module_ceiling deps: Option,usize,usize Design: new pure-function @ crates/gateway-stt/tests/it/architecture.rs::calls_associated_method deps: &str,&str,&str Design: new pure-function @ crates/gateway-stt/tests/it/architecture.rs::refcount_introspection deps: &str Design: new oversized-unit @ crates/gateway-stt/tests/it/generation.rs::active_replacement_drains_request_and_job_before_unload_and_publication Pending: N24 - compounds Pending: N25 - compounds Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- .github/workflows/stt-miri.yml | 8 +- .../module-ceilings.toml | 6 +- .../gateway-stt-engine/module-ceilings.toml | 6 +- crates/gateway-stt-engine/src/engine.rs | 8 +- .../gateway-stt-engine/src/test_fixtures.rs | 8 +- crates/gateway-stt-engine/src/worker.rs | 47 +- .../tests/startup_cleanup.rs | 6 +- crates/gateway-stt/module-ceilings.toml | 34 +- crates/gateway-stt/src/artifacts.rs | 8 + crates/gateway-stt/src/batch.rs | 1 - crates/gateway-stt/src/generation.rs | 359 ++++++++----- crates/gateway-stt/src/generation/lease.rs | 126 +++++ crates/gateway-stt/src/generation/snapshot.rs | 98 ++++ crates/gateway-stt/src/lib.rs | 1 + crates/gateway-stt/src/realtime/input.rs | 11 +- crates/gateway-stt/src/realtime/session.rs | 8 +- .../gateway-stt/src/realtime/session/state.rs | 7 +- crates/gateway-stt/src/replacement.rs | 473 ++++++++++++++++++ crates/gateway-stt/src/service.rs | 14 +- crates/gateway-stt/src/stt.rs | 22 +- crates/gateway-stt/src/take.rs | 7 +- crates/gateway-stt/src/take/finalization.rs | 8 +- crates/gateway-stt/src/test_fixtures.rs | 106 +--- .../src/test_fixtures/generation.rs | 104 ++++ .../gateway-stt/src/test_fixtures/native.rs | 42 ++ .../gateway-stt/src/test_fixtures/segment.rs | 12 + crates/gateway-stt/tests/it/architecture.rs | 152 +++++- crates/gateway-stt/tests/it/generation.rs | 341 +++++++++++++ crates/gateway-stt/tests/it/main.rs | 2 + .../gateway-whisper-ffi/module-ceilings.toml | 4 +- vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- vibe/archdoc-next.md | 9 +- 32 files changed, 1716 insertions(+), 324 deletions(-) create mode 100644 crates/gateway-stt/src/generation/lease.rs create mode 100644 crates/gateway-stt/src/generation/snapshot.rs create mode 100644 crates/gateway-stt/src/replacement.rs create mode 100644 crates/gateway-stt/src/test_fixtures/generation.rs create mode 100644 crates/gateway-stt/src/test_fixtures/native.rs create mode 100644 crates/gateway-stt/src/test_fixtures/segment.rs create mode 100644 crates/gateway-stt/tests/it/generation.rs diff --git a/.github/workflows/stt-miri.yml b/.github/workflows/stt-miri.yml index 91dedcb8..fb615886 100644 --- a/.github/workflows/stt-miri.yml +++ b/.github/workflows/stt-miri.yml @@ -37,10 +37,10 @@ jobs: - name: Check pure STT worker ownership and queues run: cargo +nightly-2026-09-05 miri test -p gateway-stt-engine --features test-fixtures miri_ - # Session filters cover registry, immutable input and committed-item - # ownership, result and final-segment bounds, audio state, and epochs. - # Socket and spawned-task tests stay native. - - name: Check pure STT session and item ownership + # 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: diff --git a/crates/gateway-stt-backend-whisper/module-ceilings.toml b/crates/gateway-stt-backend-whisper/module-ceilings.toml index ec393863..7a31b01c 100644 --- a/crates/gateway-stt-backend-whisper/module-ceilings.toml +++ b/crates/gateway-stt-backend-whisper/module-ceilings.toml @@ -1,6 +1,6 @@ # Exact source and public-root ratchets for the safe Whisper backend. -# Physical lines include comments and blanks. A source file may shrink but -# may not exceed its recorded ceiling. +# Physical lines include comments and blanks. Every recorded ceiling equals +# the measured file size, so any size change updates this manifest explicitly. public_root_budget = 2 @@ -9,5 +9,5 @@ public_root_budget = 2 [modules] "config.rs" = 32 "lib.rs" = 8 -"model.rs" = 301 +"model.rs" = 293 "prompt.rs" = 233 diff --git a/crates/gateway-stt-engine/module-ceilings.toml b/crates/gateway-stt-engine/module-ceilings.toml index c8444a01..ef2090fb 100644 --- a/crates/gateway-stt-engine/module-ceilings.toml +++ b/crates/gateway-stt-engine/module-ceilings.toml @@ -1,6 +1,6 @@ # Exact source and public-root ratchets for the backend-neutral STT engine. -# Physical lines include comments and blanks. A source file may shrink but -# may not exceed its recorded ceiling. +# Physical lines include comments and blanks. Every recorded ceiling equals +# the measured file size, so any size change updates this manifest explicitly. public_root_budget = 7 @@ -15,4 +15,4 @@ public_root_budget = 7 "startup.rs" = 48 "test_fixtures.rs" = 638 "translation.rs" = 28 -"worker.rs" = 451 +"worker.rs" = 460 diff --git a/crates/gateway-stt-engine/src/engine.rs b/crates/gateway-stt-engine/src/engine.rs index c436c111..802393d2 100644 --- a/crates/gateway-stt-engine/src/engine.rs +++ b/crates/gateway-stt-engine/src/engine.rs @@ -48,13 +48,13 @@ impl SttEngine { .ok_or_else(|| { TranscribeError::InvalidConfig("stt.startup_timeout is too large".to_owned()) })?; - let (mut transcriber, interim_init) = spawn( + let (transcriber, interim_init) = spawn( "stt-interim", Arc::clone(&factory), DecodeMode::Interim, INTERIM_JOB_CAPACITY, )?; - let (mut final_worker, final_init) = match spawn( + let (final_worker, final_init) = match spawn( "stt-final", Arc::clone(&factory), DecodeMode::Final, @@ -164,12 +164,12 @@ impl SttEngine { /// [`TranscribeError::ShutdownFailures`] for multiple panicked workers. /// Both workers are still joined and every failure remains visible on /// repeated calls. - pub fn shutdown(&mut self) -> Result<(), TranscribeError> { + 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) = &mut self.final_pass + if let Some(final_pass) = &self.final_pass && let Err(error) = final_pass.shutdown() { cleanup.push(error); diff --git a/crates/gateway-stt-engine/src/test_fixtures.rs b/crates/gateway-stt-engine/src/test_fixtures.rs index 9d436eb7..b0975715 100644 --- a/crates/gateway-stt-engine/src/test_fixtures.rs +++ b/crates/gateway-stt-engine/src/test_fixtures.rs @@ -373,7 +373,7 @@ mod tests { interim.push_text("interim"); let final_decoder = ScriptedDecoder::new(); final_decoder.push_text("final"); - let mut engine = SttEngine::new( + let engine = SttEngine::new( ScriptedModelFactory::new(interim.clone()) .with_final(final_decoder.clone()) .with_gpu_available(true), @@ -486,7 +486,7 @@ mod tests { let waiter = std::thread::spawn(move || waiter_decoder.wait_for_requests(1, Duration::from_secs(1))); wait_until_waiter_is_registered(&interim); - let mut engine = SttEngine::new(ScriptedModelFactory::new(interim.clone()), policy()) + let engine = SttEngine::new(ScriptedModelFactory::new(interim.clone()), policy()) .expect("scripted worker starts"); engine @@ -512,7 +512,7 @@ mod tests { let interim = ScriptedDecoder::new(); interim.push_error(SENTINEL); - let mut engine = SttEngine::new(ScriptedModelFactory::new(interim.clone()), policy()) + 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(), "")) @@ -622,7 +622,7 @@ mod tests { fn shutdown_surfaces_join_panic_and_remains_idempotent() { let interim = ScriptedDecoder::new(); interim.panic_on_drop(); - let mut engine = SttEngine::new(ScriptedModelFactory::new(interim.clone()), policy()) + let engine = SttEngine::new(ScriptedModelFactory::new(interim.clone()), policy()) .expect("scripted worker starts"); assert!(matches!( diff --git a/crates/gateway-stt-engine/src/worker.rs b/crates/gateway-stt-engine/src/worker.rs index d4a00410..d35e87d6 100644 --- a/crates/gateway-stt-engine/src/worker.rs +++ b/crates/gateway-stt-engine/src/worker.rs @@ -2,7 +2,7 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, mpsc}; +use std::sync::{Arc, Mutex, PoisonError, mpsc}; use crate::{DecodeMode, DecodeRequest, Decoder, ModelFactory, TranscribeError}; @@ -17,8 +17,13 @@ struct Job { /// Handle to a decoder confined to its worker thread. #[derive(Debug)] pub(crate) struct Transcriber { - job_tx: Option>, + state: Mutex, stopping: Arc, +} + +#[derive(Debug)] +struct TranscriberState { + job_tx: Option>, worker: Option>, join_panicked: bool, } @@ -43,10 +48,12 @@ impl Transcriber { .map_err(TranscribeError::SpawnWorker)?; Ok(( Self { - job_tx: Some(job_tx), + state: Mutex::new(TranscriberState { + job_tx: Some(job_tx), + worker: Some(worker), + join_panicked: false, + }), stopping, - worker: Some(worker), - join_panicked: false, }, init_rx, )) @@ -58,7 +65,8 @@ impl Transcriber { ) -> Result>, TranscribeError> { let (reply, reply_rx) = tokio::sync::oneshot::channel(); - let Some(job_tx) = &self.job_tx else { + 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 @@ -78,26 +86,28 @@ impl Transcriber { reply_rx.await.map_err(|_| TranscribeError::WorkerGone)? } - pub(super) fn shutdown(&mut self) -> Result<(), TranscribeError> { + pub(super) fn shutdown(&self) -> Result<(), TranscribeError> { self.stopping.store(true, Ordering::Release); - drop(self.job_tx.take()); - if let Some(worker) = self.worker.take() { - self.join_panicked = worker.join().is_err(); + 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 self.join_panicked { + if state.join_panicked { Err(TranscribeError::ShutdownPanicked) } else { Ok(()) } } - pub(super) fn abandon_startup(&mut self) { + pub(super) fn abandon_startup(&self) { self.stopping.store(true, Ordering::Release); - drop(self.job_tx.take()); + 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(self.worker.take()); + drop(state.worker.take()); } pub(super) fn startup_failure( @@ -298,7 +308,7 @@ mod tests { } fn assert_queue_boundary(mode: DecodeMode, capacity: usize) { - let (mut worker, control) = parked_worker(mode, capacity); + let (worker, control) = parked_worker(mode, capacity); let running = worker .submit(request(mode)) .expect("running job is admitted"); @@ -354,7 +364,7 @@ mod tests { #[cfg(feature = "test-fixtures")] #[test] fn miri_shutdown_releases_worker_ownership_once() { - let (mut worker, control) = parked_worker(DecodeMode::Interim, INTERIM_JOB_CAPACITY); + let (worker, control) = parked_worker(DecodeMode::Interim, INTERIM_JOB_CAPACITY); worker.shutdown().expect("first shutdown joins"); worker.shutdown().expect("second shutdown is idempotent"); @@ -371,7 +381,7 @@ mod tests { #[test] fn cancellation_while_running_discards_only_that_reply() { - let (mut worker, control) = parked_worker(DecodeMode::Interim, INTERIM_JOB_CAPACITY); + let (worker, control) = parked_worker(DecodeMode::Interim, INTERIM_JOB_CAPACITY); let cancelled = worker .submit(request(DecodeMode::Interim)) .expect("running job is admitted"); @@ -401,7 +411,7 @@ mod tests { #[test] fn shutdown_joins_the_worker_and_is_idempotent() { - let (mut worker, control) = parked_worker(DecodeMode::Interim, INTERIM_JOB_CAPACITY); + 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( @@ -424,7 +434,6 @@ mod tests { let stopping = Arc::clone(&worker.stopping); let (returned_tx, returned_rx) = mpsc::channel(); let shutdown = std::thread::spawn(move || { - let mut worker = worker; worker.shutdown().expect("worker joins"); let _ignored = returned_tx.send(()); }); diff --git a/crates/gateway-stt-engine/tests/startup_cleanup.rs b/crates/gateway-stt-engine/tests/startup_cleanup.rs index a99caae3..5a288d66 100644 --- a/crates/gateway-stt-engine/tests/startup_cleanup.rs +++ b/crates/gateway-stt-engine/tests/startup_cleanup.rs @@ -189,7 +189,7 @@ 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(mut engine) = SttEngine::new( + let Ok(engine) = SttEngine::new( ScriptedModelFactory::new(interim.clone()).with_final(final_decoder.clone()), policy(), ) else { @@ -212,7 +212,7 @@ 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(mut engine) = SttEngine::new( + let Ok(engine) = SttEngine::new( ScriptedModelFactory::new(interim.clone()).with_final(final_decoder.clone()), policy(), ) else { @@ -244,7 +244,7 @@ fn shutdown_aggregates_both_panics_and_repeats_the_complete_failure_set() { interim.panic_on_drop(); let final_decoder = ScriptedDecoder::new(); final_decoder.panic_on_drop(); - let Ok(mut engine) = SttEngine::new( + let Ok(engine) = SttEngine::new( ScriptedModelFactory::new(interim.clone()).with_final(final_decoder.clone()), policy(), ) else { diff --git a/crates/gateway-stt/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index dd8a10f6..a699d0dd 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -1,6 +1,6 @@ # Exact source and public-root ratchets for the gateway STT facade. -# Physical lines include comments and blanks. A source file may shrink but -# may not exceed its recorded ceiling. +# Physical lines include comments and blanks. Every recorded ceiling equals +# the measured file size, so any size change updates this manifest explicitly. public_root_budget = 6 @@ -9,35 +9,41 @@ target_step = "Step 30" destination = "removal after the Realtime route and Workshop relay replace the legacy socket" [modules] -"artifacts.rs" = 294 +"artifacts.rs" = 302 "audio.rs" = 397 -"batch.rs" = 349 +"batch.rs" = 347 "batch/native_tests.rs" = 113 "batch/tests.rs" = 160 -"generation.rs" = 247 -"lib.rs" = 40 +"generation.rs" = 336 +"generation/lease.rs" = 126 +"generation/snapshot.rs" = 98 +"lib.rs" = 41 "model.rs" = 55 "realtime/mod.rs" = 16 -"realtime/input.rs" = 198 +"realtime/input.rs" = 195 "realtime/item.rs" = 157 "realtime/query.rs" = 70 "realtime/registry.rs" = 200 "realtime/result_mailbox.rs" = 207 -"realtime/session.rs" = 438 +"realtime/session.rs" = 436 "realtime/session/items.rs" = 134 -"realtime/session/state.rs" = 83 +"realtime/session/state.rs" = 82 "realtime/wire.rs" = 24 "realtime/wire/client.rs" = 363 -"realtime/wire/server.rs" = 389 -"realtime/wire/shared.rs" = 218 +"realtime/wire/server.rs" = 388 +"realtime/wire/shared.rs" = 217 "realtime/wire/tests.rs" = 278 +"replacement.rs" = 473 "segment.rs" = 239 "service.rs" = 104 "status.rs" = 54 "stt.rs" = 725 "take.rs" = 420 "take/agreement.rs" = 116 -"take/finalization.rs" = 175 +"take/finalization.rs" = 177 "take/state.rs" = 82 -"take/text.rs" = 10 -"test_fixtures.rs" = 480 +"take/text.rs" = 9 +"test_fixtures.rs" = 420 +"test_fixtures/generation.rs" = 104 +"test_fixtures/native.rs" = 42 +"test_fixtures/segment.rs" = 12 diff --git a/crates/gateway-stt/src/artifacts.rs b/crates/gateway-stt/src/artifacts.rs index 0d56aba5..00d8efbd 100644 --- a/crates/gateway-stt/src/artifacts.rs +++ b/crates/gateway-stt/src/artifacts.rs @@ -147,6 +147,14 @@ pub enum SpeechError { #[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, + /// Multipart framing could not be decoded. #[non_exhaustive] #[error("invalid multipart transcription request")] diff --git a/crates/gateway-stt/src/batch.rs b/crates/gateway-stt/src/batch.rs index b91066e8..b3fa0f72 100644 --- a/crates/gateway-stt/src/batch.rs +++ b/crates/gateway-stt/src/batch.rs @@ -128,7 +128,6 @@ async fn transcribe( }; let (samples, duration) = decode_wav(&form.file)?; let text = generation - .engine() .decode(DecodeRequest::new( mode, samples, diff --git a/crates/gateway-stt/src/generation.rs b/crates/gateway-stt/src/generation.rs index 773e670c..e3af23f0 100644 --- a/crates/gateway-stt/src/generation.rs +++ b/crates/gateway-stt/src/generation.rs @@ -1,75 +1,33 @@ -//! Atomic publication of one complete speech generation. +//! 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}; -use gateway_stt_engine::{DecodeMode, EnginePolicy, SttEngine}; +use gateway_stt_engine::{DecodeMode, EnginePolicy, ModelFactory, SttEngine}; use crate::artifacts::{PreparedSpeech, SpeechError}; use crate::model::{ModelNames, SpeechModelInfo}; +use crate::replacement::{DrainOutcome, ReplacementCoordinator, ReplacementPermit}; use crate::status::SpeechStatus; -#[derive(Debug, Clone, Copy)] -enum Backend { - Whisper, - #[cfg(feature = "test-fixtures")] - Scripted, -} +mod lease; +mod snapshot; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Admission { - Open, -} +#[cfg(feature = "test-fixtures")] +pub(crate) use lease::GenerationJob; +pub(crate) use lease::GenerationLease; +use snapshot::{Backend, Generation}; + +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, -} - -#[derive(Debug)] -pub(crate) struct Generation { - id: u64, - backend: Backend, - engine: Arc, - names: ModelNames, - guidance: Arc<[String]>, - admission: Admission, -} - -impl Generation { - pub(crate) fn engine(&self) -> &SttEngine { - &self.engine - } - - pub(crate) fn engine_handle(&self) -> Arc { - Arc::clone(&self.engine) - } - - pub(crate) fn guidance(&self) -> &[String] { - &self.guidance - } - - 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) - } - - fn models(&self) -> Vec { - self.names.infos() - } - - fn select(&self, name: &str) -> Option { - (self.admission == Admission::Open) - .then(|| self.names.select(name)) - .flatten() - } + permit: ReplacementPermit, } #[derive(Debug)] @@ -77,6 +35,7 @@ struct Shared { active: RwLock>>, next_generation: AtomicU64, changes: tokio::sync::watch::Sender, + replacements: Arc, } /// Cloneable internal state used by service methods and private handlers. @@ -93,6 +52,7 @@ impl Default for GenerationState { active: RwLock::new(None), next_generation: AtomicU64::new(1), changes, + replacements: Arc::new(ReplacementCoordinator::default()), }), } } @@ -100,59 +60,77 @@ impl Default for GenerationState { impl GenerationState { pub(crate) fn stage(&self, prepared: PreparedSpeech) -> Result { - let generation = 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)?; - let engine = SttEngine::new(factory, policy).map_err(SpeechError::Engine)?; - Ok(Generation { - id: self.next_id(), - backend: Backend::Whisper, - engine: Arc::new(engine), - names: prepared.names, - guidance: prepared.guidance.into(), - admission: Admission::Open, + self.replace_with(GENERATION_QUIESCENCE_TIMEOUT, move |id| { + 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)?; + Generation::from_factory( + id, + Backend::Whisper, + factory, + policy, + prepared.names, + prepared.guidance, + ) }) - }) - .transpose()?; - Ok(SpeechReplacement { - owner: Arc::downgrade(&self.shared), - generation, + .transpose() }) } #[cfg(feature = "test-fixtures")] pub(crate) fn stage_scripted( + &self, + factory: impl ModelFactory, + final_model: Option, + gpu_available: bool, + timeout: Duration, + ) -> Result { + let policy = EnginePolicy::new(15, 500, gpu_available).map_err(SpeechError::Engine)?; + self.replace_with(timeout, move |id| { + Generation::from_factory( + id, + Backend::Scripted, + factory, + policy, + ModelNames::new("scripted-interim".to_owned(), final_model), + Vec::new(), + ) + .map(Some) + }) + } + + #[cfg(feature = "test-fixtures")] + pub(crate) fn stage_loaded_scripted( &self, engine: SttEngine, interim: String, final_model: Option, guidance: Vec, - ) -> SpeechReplacement { - SpeechReplacement { - owner: Arc::downgrade(&self.shared), - generation: Some(Generation { - id: self.next_id(), - backend: Backend::Scripted, - engine: Arc::new(engine), - names: ModelNames::new(interim, final_model), - guidance: guidance.into(), - admission: Admission::Open, - }), - } + timeout: Duration, + ) -> Result { + self.replace_with(timeout, move |id| { + Ok(Some(Generation::from_engine( + id, + Backend::Scripted, + engine, + ModelNames::new(interim, final_model), + guidance, + ))) + }) } pub(crate) fn commit(&self, replacement: SpeechReplacement) -> Result<(), SpeechError> { @@ -163,48 +141,74 @@ impl GenerationState { return Err(SpeechError::ReplacementOwner); } - let published = replacement.generation.map(Arc::new); + let mut replacement = replacement; + let published = replacement.generation.take().map(Arc::new); let revision = published .as_ref() .map_or_else(|| self.next_id(), |generation| generation.id); - let mut active = self - .shared - .active - .write() - .unwrap_or_else(PoisonError::into_inner); - if active.is_some() { - return Err(SpeechError::GenerationActive); + let committed = replacement.permit.with_current(|| { + let mut active = self + .shared + .active + .write() + .unwrap_or_else(PoisonError::into_inner); + if active.is_some() { + return false; + } + *active = published; + drop(active); + self.shared.changes.send_replace(revision); + true + }); + match committed { + Some(true) => {} + Some(false) => return Err(SpeechError::GenerationActive), + None => return Err(SpeechError::ReplacementInvalidated), } - *active = published; - drop(active); - self.shared.changes.send_replace(revision); Ok(()) } pub(crate) fn shutdown(&self) { + let _shutdown = self.shared.replacements.begin_shutdown(); let generation = self + .shared + .active + .read() + .unwrap_or_else(PoisonError::into_inner) + .as_ref() + .map(Arc::clone); + let Some(generation) = generation else { + return; + }; + generation.admission.shutdown(); + self.shared.changes.send_replace(self.next_id()); + generation.admission.wait_until_idle(); + let retired = self .shared .active .write() .unwrap_or_else(PoisonError::into_inner) - .take(); - if generation.is_some() { - self.shared.changes.send_replace(self.next_id()); + .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"); } - unload(generation); } - pub(crate) fn active(&self) -> Option> { - self.shared + pub(crate) fn active(&self) -> Option { + let active = self + .shared .active .read() - .unwrap_or_else(PoisonError::into_inner) - .as_ref() - .filter(|generation| generation.admission == Admission::Open) - .cloned() + .unwrap_or_else(PoisonError::into_inner); + let generation = active.as_ref()?; + let admission = generation.admission.admit()?; + Some(GenerationLease::new(Arc::clone(generation), admission)) } - pub(crate) fn select(&self, name: &str) -> Option<(Arc, DecodeMode)> { + pub(crate) fn select(&self, name: &str) -> Option<(GenerationLease, DecodeMode)> { let generation = self.active()?; let mode = generation.select(name)?; Some((generation, mode)) @@ -215,33 +219,118 @@ impl GenerationState { } pub(crate) fn status(&self) -> SpeechStatus { - self.active() + let active = self + .shared + .active + .read() + .unwrap_or_else(PoisonError::into_inner); + active .as_deref() + .filter(|generation| generation.admission.is_open()) .map_or_else(SpeechStatus::inactive, Generation::status) } pub(crate) fn models(&self) -> Vec { - self.active() + let active = self + .shared + .active + .read() + .unwrap_or_else(PoisonError::into_inner); + active .as_deref() + .filter(|generation| generation.admission.is_open()) .map_or_else(Vec::new, Generation::models) } - fn next_id(&self) -> u64 { - self.shared.next_generation.fetch_add(1, Ordering::Relaxed) + #[cfg(feature = "test-fixtures")] + pub(crate) fn counts(&self) -> Option<(usize, usize)> { + self.shared + .active + .read() + .unwrap_or_else(PoisonError::into_inner) + .as_ref() + .map(|generation| generation.admission.counts()) } -} -fn unload(generation: Option>) { - let Some(generation) = generation else { - return; - }; - let engine = Arc::clone(&generation.engine); - while Arc::strong_count(&generation) > 1 { - std::thread::sleep(std::time::Duration::from_millis(5)); + fn replace_with( + &self, + timeout: Duration, + build: impl FnOnce(u64) -> Result, SpeechError>, + ) -> Result { + let permit = self.shared.replacements.acquire(); + self.quiesce(&permit, timeout)?; + if !permit.is_current() { + return Err(SpeechError::ReplacementInvalidated); + } + let generation = build(self.next_id())?; + if !permit.is_current() { + return Err(SpeechError::ReplacementInvalidated); + } + Ok(SpeechReplacement { + owner: Arc::downgrade(&self.shared), + generation, + permit, + }) + } + + fn quiesce(&self, permit: &ReplacementPermit, timeout: Duration) -> Result<(), SpeechError> { + let generation = self + .shared + .active + .read() + .unwrap_or_else(PoisonError::into_inner) + .as_ref() + .map(Arc::clone); + let Some(generation) = generation else { + return Ok(()); + }; + let deadline = Instant::now() + .checked_add(timeout) + .ok_or(SpeechError::QuiescenceDeadline)?; + let close = permit + .with_current(|| { + let close = generation.admission.close()?; + self.shared.changes.send_replace(self.next_id()); + Some(close) + }) + .flatten() + .ok_or(SpeechError::ReplacementInvalidated)?; + match generation.admission.wait_for_idle(&close, deadline) { + DrainOutcome::TimedOut => { + let reopened = permit + .with_current(|| { + let reopened = generation.admission.reopen(&close); + if reopened { + self.shared.changes.send_replace(self.next_id()); + } + reopened + }) + .unwrap_or(false); + if reopened { + Err(SpeechError::QuiescenceDeadline) + } else { + Err(SpeechError::ReplacementInvalidated) + } + } + DrainOutcome::Invalidated => Err(SpeechError::ReplacementInvalidated), + DrainOutcome::Idle => { + let retired = permit + .with_current(|| { + self.shared + .active + .write() + .unwrap_or_else(PoisonError::into_inner) + .take_if(|active| Arc::ptr_eq(active, &generation)) + }) + .flatten() + .ok_or(SpeechError::ReplacementInvalidated)?; + drop(generation); + retired.shutdown() + } + } } - drop(generation); - while Arc::strong_count(&engine) > 1 { - std::thread::sleep(std::time::Duration::from_millis(5)); + + fn next_id(&self) -> u64 { + self.shared.next_generation.fetch_add(1, Ordering::Relaxed) } - drop(engine); } diff --git a/crates/gateway-stt/src/generation/lease.rs b/crates/gateway-stt/src/generation/lease.rs new file mode 100644 index 00000000..0f8b72fc --- /dev/null +++ b/crates/gateway-stt/src/generation/lease.rs @@ -0,0 +1,126 @@ +//! 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) 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..a9ff659f --- /dev/null +++ b/crates/gateway-stt/src/generation/snapshot.rs @@ -0,0 +1,98 @@ +//! 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)] +pub(super) struct Generation { + pub(super) id: u64, + backend: Backend, + engine: SttEngine, + names: ModelNames, + pub(super) guidance: Arc<[String]>, + pub(super) admission: Arc, +} + +impl Generation { + pub(super) fn from_engine( + id: u64, + backend: Backend, + engine: SttEngine, + names: ModelNames, + guidance: Vec, + ) -> Self { + Self { + id, + backend, + engine, + names, + guidance: guidance.into(), + admission: Arc::new(AdmissionGate::default()), + } + } + + pub(super) fn from_factory( + id: u64, + backend: Backend, + factory: impl ModelFactory, + policy: EnginePolicy, + names: ModelNames, + guidance: Vec, + ) -> Result { + let engine = SttEngine::new(factory, policy).map_err(SpeechError::Engine)?; + Ok(Self::from_engine(id, backend, engine, names, guidance)) + } + + 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 c7b2d71c..3dc23bfe 100644 --- a/crates/gateway-stt/src/lib.rs +++ b/crates/gateway-stt/src/lib.rs @@ -11,6 +11,7 @@ mod generation; mod model; #[allow(dead_code)] mod realtime; +mod replacement; mod segment; mod service; mod status; diff --git a/crates/gateway-stt/src/realtime/input.rs b/crates/gateway-stt/src/realtime/input.rs index 8b3d16c0..3cf0aaf1 100644 --- a/crates/gateway-stt/src/realtime/input.rs +++ b/crates/gateway-stt/src/realtime/input.rs @@ -1,8 +1,5 @@ -use std::sync::Arc; - -use gateway_stt_engine::SttEngine; - use crate::audio::{AudioBuffer, AudioError}; +use crate::generation::GenerationLease; use crate::take::Take; const INPUT_FORMAT: &str = "audio/pcm"; @@ -70,7 +67,7 @@ impl UncommittedInput { pub(crate) fn new( item_id: String, snapshot: InputSnapshot, - engine: Option>, + engine: Option, ) -> Self { Self::from_audio(item_id, snapshot, engine, AudioBuffer::default()) } @@ -78,7 +75,7 @@ impl UncommittedInput { pub(crate) fn first_append( item_id: String, snapshot: InputSnapshot, - engine: Option>, + engine: Option, payload: &str, ) -> Result { let mut audio = AudioBuffer::default(); @@ -89,7 +86,7 @@ impl UncommittedInput { fn from_audio( item_id: String, snapshot: InputSnapshot, - engine: Option>, + engine: Option, mut audio: AudioBuffer, ) -> Self { let guidance = if snapshot.prompt.is_empty() { diff --git a/crates/gateway-stt/src/realtime/session.rs b/crates/gateway-stt/src/realtime/session.rs index e030c479..031a35c3 100644 --- a/crates/gateway-stt/src/realtime/session.rs +++ b/crates/gateway-stt/src/realtime/session.rs @@ -1,12 +1,10 @@ use std::future::Future; -use std::sync::Arc; - -use gateway_stt_engine::SttEngine; use super::input::{InputSnapshot, UncommittedInput}; use super::item::CommittedItem; use super::registry::SessionRegistration; use super::wire::{ClientError, EffectiveSession, IdGenerator, ServerEvent}; +use crate::generation::GenerationLease; mod items; mod state; @@ -17,7 +15,7 @@ use state::SESSION_CANCEL_JOIN_CAPACITY; pub(crate) use state::{InterimEpoch, Session, SessionError}; impl Session { - pub(crate) fn new(registration: SessionRegistration, engine: Option>) -> Self { + 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) @@ -42,7 +40,7 @@ impl Session { let input = UncommittedInput::first_append( self.ids.item(), snapshot, - self.engine.as_ref().map(Arc::clone), + self.engine.clone(), payload, )?; self.input = Some(input); diff --git a/crates/gateway-stt/src/realtime/session/state.rs b/crates/gateway-stt/src/realtime/session/state.rs index 36553b43..b439adef 100644 --- a/crates/gateway-stt/src/realtime/session/state.rs +++ b/crates/gateway-stt/src/realtime/session/state.rs @@ -1,10 +1,9 @@ use std::collections::HashMap; -use std::sync::Arc; -use gateway_stt_engine::SttEngine; use tokio::task::JoinHandle; use crate::audio::AudioError; +use crate::generation::GenerationLease; use crate::realtime::input::UncommittedInput; use crate::realtime::item::CommittedItem; use crate::realtime::registry::SessionRegistration; @@ -43,7 +42,7 @@ pub(crate) enum SessionError { #[derive(Debug)] pub(crate) struct Session { pub(super) registration: Option, - pub(super) engine: Option>, + pub(super) engine: Option, pub(super) ids: IdGenerator, pub(super) effective: EffectiveSession, pub(super) input: Option, @@ -60,7 +59,7 @@ pub(crate) struct Session { impl Session { pub(super) fn empty( registration: SessionRegistration, - engine: Option>, + engine: Option, ids: IdGenerator, effective: EffectiveSession, ) -> Self { diff --git a/crates/gateway-stt/src/replacement.rs b/crates/gateway-stt/src/replacement.rs new file mode 100644 index 00000000..4c9b452f --- /dev/null +++ b/crates/gateway-stt/src/replacement.rs @@ -0,0 +1,473 @@ +//! 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 { + 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(id: u64) -> Self { + Self { + state: Arc::new(EpochState { + id, + cancelled: AtomicBool::new(false), + changed: Notify::new(), + }), + } + } + + 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, + 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(1), + 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(state.next_epoch); + 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 + ) + } + + 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/service.rs b/crates/gateway-stt/src/service.rs index b8a24911..97f6d9d5 100644 --- a/crates/gateway-stt/src/service.rs +++ b/crates/gateway-stt/src/service.rs @@ -11,7 +11,7 @@ use crate::status::SpeechStatus; /// Cloneable Gateway handle for all speech behavior. #[derive(Debug, Clone, Default)] pub struct SpeechService { - state: GenerationState, + pub(crate) state: GenerationState, } impl SpeechService { @@ -33,10 +33,10 @@ impl SpeechService { artifacts::prepare(config, progress) } - /// Loads a prepared worker generation without publishing it. + /// Serializes replacement, drains old ownership, and loads a staged generation. /// /// # Errors - /// Returns a backend, policy, or worker startup error. + /// Returns a drain deadline, backend, policy, or worker startup error. pub fn begin_replacement( &self, prepared: PreparedSpeech, @@ -47,8 +47,7 @@ impl SpeechService { /// Publishes every fact in a staged generation through one transition. /// /// # Errors - /// Returns an ownership error for a foreign token or when the caller did - /// not shut down the prior generation first. + /// Returns an ownership error for a foreign or shutdown-invalidated token. pub fn commit_replacement(&self, replacement: SpeechReplacement) -> Result<(), SpeechError> { self.state.commit(replacement) } @@ -93,12 +92,13 @@ impl SpeechService { &self, engine: gateway_stt_engine::SttEngine, final_model: Option, - ) -> SpeechReplacement { - self.state.stage_scripted( + ) -> Result { + self.state.stage_loaded_scripted( engine, "scripted-interim".to_owned(), final_model, Vec::new(), + std::time::Duration::from_secs(30), ) } } diff --git a/crates/gateway-stt/src/stt.rs b/crates/gateway-stt/src/stt.rs index fd7998ef..e05b3d03 100644 --- a/crates/gateway-stt/src/stt.rs +++ b/crates/gateway-stt/src/stt.rs @@ -10,12 +10,12 @@ use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; use axum::http::{HeaderMap, StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum::routing::get; -use gateway_stt_engine::{DecodeMode, DecodeRequest, EnginePolicy, SttEngine}; +use gateway_stt_engine::{DecodeMode, DecodeRequest, EnginePolicy}; use serde::Serialize; use tokio::sync::{mpsc, watch}; use workshop_server::{Activity, Push}; -use crate::generation::{Generation, GenerationState}; +use crate::generation::{GenerationLease, GenerationState}; use crate::take::Take; static NEXT_SESSION: AtomicU64 = AtomicU64::new(1); @@ -260,7 +260,7 @@ async fn next_status(statuses: &mut Option>) -> fn spawn_interim( session: u64, generation: u64, - engine: Arc, + engine: GenerationLease, state: Arc, reporter: Reporter, ) -> ActiveTake { @@ -321,7 +321,7 @@ fn spawn_interim( async fn final_transcript( session: u64, - engine: &SttEngine, + engine: &GenerationLease, take: &Take, reporter: &Reporter, ) -> String { @@ -371,7 +371,7 @@ fn truncation_message(window_samples: usize, dropped: usize) -> String { /// 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, take: &Take, reporter: &Reporter) { +fn warn_if_truncated(session: u64, engine: &GenerationLease, take: &Take, reporter: &Reporter) { let uncommitted = take.fallback_len(); let window = engine.window_samples(); let Some(dropped) = truncation_drop(uncommitted, window) else { @@ -392,7 +392,7 @@ fn warn_if_truncated(session: u64, engine: &SttEngine, take: &Take, reporter: &R async fn stop_transcript( session: u64, - engine: Option<&SttEngine>, + engine: Option<&GenerationLease>, take: &Take, reporter: &Reporter, ) -> String { @@ -426,10 +426,10 @@ async fn stop_transcript( fn begin_take( session: u64, generation: u64, - active: Option<&Arc>, + active: Option<&GenerationLease>, reporter: &Reporter, ) -> (Arc, Option) { - let engine = active.map(|generation| generation.engine_handle()); + let engine = active.cloned(); let guidance = active.map_or_else(Vec::new, |generation| generation.guidance().to_vec()); let state = Arc::new(Take::new(guidance, engine.clone())); let active = engine.map(|engine| { @@ -488,7 +488,7 @@ impl SessionAudio { } } - fn receive(&mut self, payload: &[u8], engine: Option<&SttEngine>, reporter: &Reporter) { + fn receive(&mut self, payload: &[u8], engine: Option<&GenerationLease>, reporter: &Reporter) { let samples: Vec = payload .as_chunks::<4>() .0 @@ -516,8 +516,8 @@ impl SessionAudio { } } -fn active_engine(generation: Option<&Arc>) -> Option<&SttEngine> { - generation.map(|generation| generation.engine()) +fn active_engine(generation: Option<&GenerationLease>) -> Option<&GenerationLease> { + generation } async fn run_session( diff --git a/crates/gateway-stt/src/take.rs b/crates/gateway-stt/src/take.rs index b94caa93..da50a390 100644 --- a/crates/gateway-stt/src/take.rs +++ b/crates/gateway-stt/src/take.rs @@ -2,10 +2,11 @@ use std::sync::Arc; -use gateway_stt_engine::SttEngine; #[cfg(test)] use gateway_stt_engine::TranscribeError; +use crate::generation::GenerationLease; + mod agreement; mod finalization; mod state; @@ -32,11 +33,11 @@ pub(crate) struct Take { } impl Take { - pub(crate) fn new(guidance: Vec, engine: Option>) -> Self { + 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(|engine| engine.has_final_pass()) + .filter(GenerationLease::has_final_pass) .map(|engine| spawn_final_pipeline(engine, Arc::clone(&guidance), Arc::clone(&state))); Self { guidance, diff --git a/crates/gateway-stt/src/take/finalization.rs b/crates/gateway-stt/src/take/finalization.rs index 944c81c6..c72ceef6 100644 --- a/crates/gateway-stt/src/take/finalization.rs +++ b/crates/gateway-stt/src/take/finalization.rs @@ -3,9 +3,11 @@ use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; -use gateway_stt_engine::{DecodeMode, DecodeRequest, SttEngine, TranscribeError}; +use gateway_stt_engine::{DecodeMode, DecodeRequest, TranscribeError}; use tokio::sync::{mpsc, oneshot}; +use crate::generation::GenerationLease; + use super::state::TakeState; pub(super) type TakeFinalization = Pin> + Send>>; @@ -103,7 +105,7 @@ pub(super) fn reserve_segment(pending_segments: &AtomicUsize) -> bool { } pub(super) fn spawn_final_pipeline( - engine: Arc, + engine: GenerationLease, guidance: Arc<[String]>, state: Arc, ) -> FinalPipeline { @@ -115,7 +117,7 @@ pub(super) fn spawn_final_pipeline( state, Arc::clone(&pending_segments), move |samples, guidance, finalized| { - let engine = Arc::clone(&engine); + let engine = engine.clone(); async move { if !engine.has_final_pass() { return None; diff --git a/crates/gateway-stt/src/test_fixtures.rs b/crates/gateway-stt/src/test_fixtures.rs index 880183ba..70564943 100644 --- a/crates/gateway-stt/src/test_fixtures.rs +++ b/crates/gateway-stt/src/test_fixtures.rs @@ -1,56 +1,31 @@ -//! Native fixtures used only by this crate's unit tests. +//! Deterministic fixtures split by service responsibility. -#[cfg(test)] -use std::path::{Path, PathBuf}; - -#[cfg(feature = "test-fixtures")] -pub use gateway_stt_engine::DecodeMode; #[cfg(feature = "test-fixtures")] -pub use gateway_stt_engine::test_fixtures::{ScriptedDecoder, ScriptedModelFactory}; +use std::future::Future; #[cfg(feature = "test-fixtures")] use crate::realtime::{CommitReceipt, InterimEpoch, ItemResult, Session, SessionRegistry}; + #[cfg(feature = "test-fixtures")] -use crate::{SpeechError, SpeechService}; +mod generation; +#[cfg(all(test, not(miri)))] +mod native; #[cfg(feature = "test-fixtures")] -use gateway_stt_engine::{EnginePolicy, SttEngine}; +mod segment; + #[cfg(feature = "test-fixtures")] -use std::future::Future; +pub use gateway_stt_engine::DecodeMode; #[cfg(feature = "test-fixtures")] -use std::sync::Arc; - -/// Builds a speech service around deterministic scripted workers. -/// -/// # Errors -/// Returns engine policy, startup, or worker construction failures. +pub use gateway_stt_engine::test_fixtures::{ScriptedDecoder, ScriptedModelFactory}; #[cfg(feature = "test-fixtures")] -pub fn scripted_service( - factory: ScriptedModelFactory, - window_seconds: u64, - interval_ms: u64, -) -> Result { - let gpu_available = factory.gpu_available(); - let policy = EnginePolicy::new(window_seconds, interval_ms, gpu_available) - .map_err(SpeechError::Engine)?; - let engine = SttEngine::new(factory, policy).map_err(SpeechError::Engine)?; - let final_name = engine.has_final_pass().then(|| "scripted-final".to_owned()); - let service = SpeechService::new(); - let replacement = service.scripted_replacement(engine, final_name); - service.commit_replacement(replacement)?; - Ok(service) -} - -/// Returns every closed speech range produced by the service segmenter. +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")] -#[must_use] -pub fn segment_ranges(samples: &[f32]) -> Vec> { - let mut segmenter = crate::segment::Segmenter::new(); - let mut ranges = Vec::new(); - while let Some(range) = segmenter.poll(samples) { - ranges.push(range); - } - ranges -} +pub use segment::segment_ranges; /// A deterministic registry for focused Realtime session integration tests. #[cfg(feature = "test-fixtures")] @@ -81,11 +56,13 @@ impl RealtimeSessionRegistryFixture { factory: ScriptedModelFactory, ) -> Result { let registration = self.inner.register().map_err(|error| error.to_string())?; - let policy = EnginePolicy::new(15, 500, factory.gpu_available()) - .map_err(|error| error.to_string())?; - let engine = SttEngine::new(factory, policy).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(Arc::new(engine))), + session: Session::new(registration, Some(engine)), }) } @@ -441,40 +418,3 @@ fn result_value(result: ItemResult) -> serde_json::Value { }), } } - -#[cfg(test)] -pub(crate) fn require_model() -> PathBuf { - require_fixture("PROMPTFORGE_WHISPER_MODEL", "ggml-tiny.en.bin") -} - -#[cfg(test)] -pub(crate) fn jfk_samples() -> Vec { - let path = require_fixture("PROMPTFORGE_WHISPER_AUDIO", "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() -} - -#[cfg(test)] -fn require_fixture(variable: &str, fallback: &str) -> PathBuf { - let path = std::env::var_os(variable).map_or_else( - || { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../gateway-stt-backend-whisper/tests/fixtures") - .join(fallback) - }, - PathBuf::from, - ); - assert!( - path.is_file(), - "native test fixture is missing: {}", - path.display() - ); - path -} 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..38bd676e --- /dev/null +++ b/crates/gateway-stt/src/test_fixtures/generation.rs @@ -0,0 +1,104 @@ +//! Deterministic generation lifecycle fixtures. + +use std::time::Duration; + +use gateway_stt_engine::{EnginePolicy, SttEngine}; + +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 policy = EnginePolicy::new(window_seconds, interval_ms, gpu_available) + .map_err(SpeechError::Engine)?; + let engine = SttEngine::new(factory, policy).map_err(SpeechError::Engine)?; + let final_name = engine.has_final_pass().then(|| "scripted-final".to_owned()); + let service = SpeechService::new(); + let replacement = service.scripted_replacement(engine, final_name)?; + 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..1fbc8393 --- /dev/null +++ b/crates/gateway-stt/src/test_fixtures/native.rs @@ -0,0 +1,42 @@ +//! 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}; + +pub(crate) fn require_model() -> PathBuf { + require_fixture("PROMPTFORGE_WHISPER_MODEL", "ggml-tiny.en.bin") +} + +pub(crate) fn jfk_samples() -> Vec { + let path = require_fixture("PROMPTFORGE_WHISPER_AUDIO", "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 require_fixture(variable: &str, fallback: &str) -> PathBuf { + let path = std::env::var_os(variable).map_or_else( + || { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../gateway-stt-backend-whisper/tests/fixtures") + .join(fallback) + }, + PathBuf::from, + ); + assert!( + path.is_file(), + "native test fixture is missing: {}", + path.display() + ); + path +} 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..f3f8bb2e --- /dev/null +++ b/crates/gateway-stt/src/test_fixtures/segment.rs @@ -0,0 +1,12 @@ +//! 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(range) = segmenter.poll(samples) { + ranges.push(range); + } + ranges +} diff --git a/crates/gateway-stt/tests/it/architecture.rs b/crates/gateway-stt/tests/it/architecture.rs index 3230f2c6..0299833c 100644 --- a/crates/gateway-stt/tests/it/architecture.rs +++ b/crates/gateway-stt/tests/it/architecture.rs @@ -445,6 +445,26 @@ fn validate_migration_targets(crate_name: &str, config: &CeilingsFile) -> Result Ok(()) } +fn validate_module_ceiling( + lines: usize, + ceiling: usize, + settled_limit: Option, +) -> Result<(), String> { + if lines != ceiling { + return Err(format!( + "measured {lines} physical lines but the exact ceiling is {ceiling}" + )); + } + if let Some(limit) = settled_limit + && lines > limit + { + return Err(format!( + "settled module has {lines} physical lines above the {limit}-line limit" + )); + } + Ok(()) +} + #[test] fn module_ceilings_cover_sources_and_name_migration_targets() { for crate_name in STT_CRATES { @@ -471,15 +491,28 @@ fn module_ceilings_cover_sources_and_name_migration_targets() { ); for (module, lines) in measured { let ceiling = config.modules[&module]; - assert!( - lines <= ceiling, - "{crate_name}/{module} grew to {lines} lines past its exact ceiling {ceiling}" - ); + let settled_limit = (crate_name == "gateway-stt" + && !config.migration_targets.contains_key(&module)) + .then_some(500); + validate_module_ceiling(lines, ceiling, settled_limit).unwrap_or_else(|error| { + panic!("{crate_name}/{module} violates its source policy: {error}") + }); } validate_migration_targets(crate_name, &config).unwrap_or_else(|error| panic!("{error}")); } } +#[test] +fn exact_module_ceiling_policy_rejects_spare_growth_and_settled_oversize() { + assert!(validate_module_ceiling(499, 500, Some(500)).is_err()); + assert!(validate_module_ceiling(501, 501, Some(500)).is_err()); + assert!(validate_module_ceiling(500, 500, Some(500)).is_ok()); + assert!( + validate_module_ceiling(501, 501, None).is_ok(), + "a named migration may retain an exact temporary oversize" + ); +} + #[test] fn completed_engine_migration_targets_are_removed() { let config = CeilingsFile { @@ -512,6 +545,117 @@ fn completed_step_18_migrations_are_removed() { assert_eq!(expected.keys().collect::>(), ["stt.rs"]); } +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 compiler_unsafe_lints_cover_the_stt_stack() { let workspace: toml::Value = toml::from_str(&read(&workspace_root().join("Cargo.toml"))) diff --git a/crates/gateway-stt/tests/it/generation.rs b/crates/gateway-stt/tests/it/generation.rs new file mode 100644 index 00000000..436887aa --- /dev/null +++ b/crates/gateway-stt/tests/it/generation.rs @@ -0,0 +1,341 @@ +//! 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); + 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"] + ); + 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(); + old.park_next(); + let service = service(&old); + let request_service = service.clone(); + let request = tokio::spawn(async move { + transcribe_batch(request_service, "scripted-interim", &[0.25; 16]).await + }); + let parked = old.clone(); + assert!( + tokio::task::spawn_blocking(move || parked.wait_until_parked(WAIT)) + .await + .expect("park observer joins"), + "the old generation owns one running native-equivalent job" + ); + assert_eq!(generation_counts(&service), Some((1, 1))); + + 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_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"); + assert!(!service.status().ready(), "closed admission is not ready"); + 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" + ); + + old.release(); + 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"] + ); + + service.shutdown(); + assert!(next_interim.worker_dropped()); + assert!(next_final.worker_dropped()); +} + +#[tokio::test] +async fn canceled_request_keeps_its_worker_job_owned_until_decode_returns() { + let old = ScriptedDecoder::new(); + old.park_next(); + let service = service(&old); + let request_service = service.clone(); + let request = tokio::spawn(async move { + transcribe_batch(request_service, "scripted-interim", &[0.25; 16]).await + }); + let parked = old.clone(); + assert!( + tokio::task::spawn_blocking(move || parked.wait_until_parked(WAIT)) + .await + .expect("park observer joins"), + "decode reaches the native-equivalent rendezvous" + ); + 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 = ScriptedDecoder::new(); + 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()); + + old.release(); + 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 11e412c3..681bbbaf 100644 --- a/crates/gateway-stt/tests/it/main.rs +++ b/crates/gateway-stt/tests/it/main.rs @@ -9,6 +9,8 @@ mod architecture; #[cfg(not(miri))] mod batch; #[cfg(not(miri))] +mod generation; +#[cfg(not(miri))] mod legacy_stream; #[cfg(not(miri))] mod realtime_fixtures; diff --git a/crates/gateway-whisper-ffi/module-ceilings.toml b/crates/gateway-whisper-ffi/module-ceilings.toml index 6b430154..4440174b 100644 --- a/crates/gateway-whisper-ffi/module-ceilings.toml +++ b/crates/gateway-whisper-ffi/module-ceilings.toml @@ -1,6 +1,6 @@ # Exact source and public-root ratchets for the Whisper FFI leaf. -# Physical lines include comments and blanks. A source file may shrink but -# may not exceed its recorded ceiling. +# Physical lines include comments and blanks. Every recorded ceiling equals +# the measured file size, so any size change updates this manifest explicitly. public_root_budget = 6 diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index de748781..b0a9529e 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -562,7 +562,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 5808156b..483b7d09 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -140,7 +140,7 @@ N11 | observation | flag-parameter @ crates/gateway-stt-backend-whisper/src/mode 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 +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 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 N17 | observation | Violates A2 @ crates/gateway-stt/tests/it/architecture.rs: not determinable from diff | Enforce exact STT architecture ratchets 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 @@ -149,8 +149,8 @@ N20 | observation | surface-growth @ crates/gateway-stt-engine/src/lib.rs::test_ 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 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 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 -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 +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 +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 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 @@ -160,6 +160,7 @@ N31 | observation | global-state @ crates/gateway-stt/src/realtime/wire/shared.r 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 -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 +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 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 +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 From 467a26225cf0d50dfcc2b4d852523b9f640bd95a Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 13:36:11 -0700 Subject: [PATCH 29/86] Make profile replacement transactional Stage profile persistence and replacement workers before publishing one live snapshot. Restore prior routing and speech generations after determinate failures, and request controlled shutdown when startup or persistence becomes indeterminate. Keep pending readers behind the persistence-to-publication boundary so disk and live state remain consistent. - `GenerationSpec` retains restartable worker construction state, while `SpeechReplacement` reconstructs the old generation when staging aborts or fails determinately. - `PreparedPersistence` writes and syncs temporary files before cutover, atomically renames authoritative targets, verifies committed bytes, and syncs parent directories where supported. - `commit_switch` holds the publication lock from persistence through speech and live-state publication. Pending and dirty readers take the same lock. - `request_fatal_shutdown` cancels the command, fires shutdown, and invalidates speech state after non-preemptible startup timeouts, indeterminate persistence, or failed rollback. - `cancellation_at_each_switch_await_preserves_the_old_routing` pins rollback at each switch phase, while generation tests cover reconstruction and aggregated cleanup failures. - `module-ceilings.toml` raises five source ceilings to their larger measured sizes while retaining exact-size ratchets. Design: extends shared-mutable-state @ crates/gateway-stt-engine/src/test_fixtures.rs::ScriptedDecoder Design: extends temporal-coupling @ crates/gateway-stt-engine/src/test_fixtures.rs::ScriptedDecoder Design: new surface-growth @ crates/gateway-stt-engine/src/translation.rs::TranscribeError::is_non_preemptible_startup_timeout boundary: pub Design: extends surface-growth @ crates/gateway-stt/src/artifacts.rs::SpeechError boundary: pub Design: extends shared-mutable-state @ crates/gateway-stt/src/generation.rs::GenerationState Design: extends encapsulated-invariant @ crates/gateway-stt/src/generation.rs::SpeechReplacement boundary: pub Design: extends oversized-unit @ crates/gateway-stt/src/generation.rs Design: new newtype @ crates/gateway-stt/src/generation/snapshot.rs::SharedFactory Design: new parameter-object @ crates/gateway-stt/src/generation/snapshot.rs::GenerationSpec Design: new shared-parameter-cluster @ crates/gateway-stt/src/generation/snapshot.rs::GenerationSpec::new Design: extends facade @ crates/gateway-stt/src/service.rs::SpeechService boundary: pub Design: extends temporal-coupling @ crates/gateway-stt/src/service.rs::SpeechService::commit_replacement Design: new surface-growth @ crates/gateway-stt/src/service.rs::SpeechService::begin_replacement_before boundary: pub Design: new surface-growth @ crates/gateway-stt/src/service.rs::SpeechService::abort_replacement boundary: pub Design: new global-state @ crates/gateway/src/config_write.rs::PERSISTENCE_SEQUENCE Design: new encapsulated-invariant @ crates/gateway/src/config_write.rs::PreparedFile boundary: persisted Design: new hidden-dependency @ crates/gateway/src/config_write.rs::persistence_temporary deps: &Path Design: new oversized-unit @ crates/gateway/src/config_write.rs Design: new shared-mutable-state @ crates/gateway/src/lib.rs::AppState Design: new encapsulated-invariant @ crates/gateway/src/lib.rs::PreparedPersistence boundary: persisted Design: new parameter-object @ crates/gateway/src/lib.rs::CutoverState Design: new pure-function @ crates/gateway/src/lib.rs::classify_speech_stage_failure deps: gateway_stt::SpeechError Design: new shared-parameter-cluster @ crates/gateway/src/lib.rs::prepare_cutover deps: &AppState,&ProfileName,&ProgressTree,&SwitchTarget,&tokio_util::sync::CancellationToken,StatePersistence,StopSet Design: new shared-parameter-cluster @ crates/gateway/src/lib.rs::run_switch_phases deps: &AppState,&ProfileName,&ProgressTree,&tokio_util::sync::CancellationToken,SwitchTarget,impl FnOnce() -> StatePersistence Design: new oversized-unit @ crates/gateway/src/lib.rs::run_switch_phases Design: new shared-parameter-cluster @ crates/gateway/src/lib.rs::restore_or_shutdown deps: &AppState,&tokio_util::sync::CancellationToken,CutoverState,GatewayError Design: new shared-parameter-cluster @ crates/gateway/src/lib.rs::request_fatal_shutdown deps: &'static str,&AppState,&tokio_util::sync::CancellationToken,GatewayError Design: new shared-parameter-cluster @ crates/gateway/src/lib.rs::rollback_commit_failure deps: &AppState,&tokio_util::sync::CancellationToken,GatewayError,RuntimeReplacement Design: new shared-parameter-cluster @ crates/gateway/src/lib.rs::commit_switch deps: &AppState,&ProfileName,&tokio_util::sync::CancellationToken,PreparedPersistence,RuntimeReplacement,SwitchTarget Design: new oversized-unit @ crates/gateway/src/lib.rs::commit_switch Design: new pure-function @ crates/gateway/src/lib.rs::start_report deps: &RuntimeReplacement Design: new oversized-unit @ crates/gateway/src/lib.rs::spawn_runtimes Design: new pure-function @ crates/gateway/src/lib.rs::two_remote_catalog Design: new oversized-unit @ crates/gateway/src/lib.rs::pending_readers_serialize_with_persistence_and_live_publication Violates: A2 - credential ownership in SpeechService is not determinable from diff Violates: A115 - control readiness during speech provisioning is not determinable from diff Pending: N21 - compounds Pending: N22 - compounds Pending: N36 - compounds Pending: N37 - compounds Pending: N38 - compounds Deferred: crates/gateway-stt/src/replacement.rs and crates/gateway/src/runner.rs remain unchanged Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- .../gateway-stt-engine/module-ceilings.toml | 4 +- .../gateway-stt-engine/src/test_fixtures.rs | 19 + crates/gateway-stt-engine/src/translation.rs | 22 + crates/gateway-stt/module-ceilings.toml | 8 +- crates/gateway-stt/src/artifacts.rs | 23 + crates/gateway-stt/src/generation.rs | 172 ++- crates/gateway-stt/src/generation/snapshot.rs | 91 +- crates/gateway-stt/src/service.rs | 36 +- .../src/test_fixtures/generation.rs | 10 +- crates/gateway-stt/tests/it/architecture.rs | 36 + crates/gateway-stt/tests/it/generation.rs | 116 +- crates/gateway/src/config_apply.rs | 5 + crates/gateway/src/config_pending.rs | 2 + crates/gateway/src/config_write.rs | 104 ++ crates/gateway/src/lib.rs | 1164 ++++++++++++++--- crates/gateway/src/shutdown.rs | 2 - crates/gateway/tests/it/profiles.rs | 38 +- vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- vibe/archdoc-next.md | 18 +- 19 files changed, 1611 insertions(+), 261 deletions(-) diff --git a/crates/gateway-stt-engine/module-ceilings.toml b/crates/gateway-stt-engine/module-ceilings.toml index ef2090fb..70d89b85 100644 --- a/crates/gateway-stt-engine/module-ceilings.toml +++ b/crates/gateway-stt-engine/module-ceilings.toml @@ -13,6 +13,6 @@ public_root_budget = 7 "lib.rs" = 18 "policy.rs" = 132 "startup.rs" = 48 -"test_fixtures.rs" = 638 -"translation.rs" = 28 +"test_fixtures.rs" = 657 +"translation.rs" = 50 "worker.rs" = 460 diff --git a/crates/gateway-stt-engine/src/test_fixtures.rs b/crates/gateway-stt-engine/src/test_fixtures.rs index b0975715..ca63e5fa 100644 --- a/crates/gateway-stt-engine/src/test_fixtures.rs +++ b/crates/gateway-stt-engine/src/test_fixtures.rs @@ -35,6 +35,7 @@ enum ConstructionState { #[derive(Debug, Default)] struct DecoderState { outcomes: VecDeque, + construction_errors: VecDeque, requests: Vec, creation_thread: Option, decode_threads: Vec, @@ -87,6 +88,11 @@ impl ScriptedDecoder { self.state().construction = ConstructionState::Armed; } + /// 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()); + } + /// Releases a decode parked by [`Self::park_next`]. pub fn release(&self) { let (_, changed) = &*self.shared; @@ -150,6 +156,12 @@ impl ScriptedDecoder { 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) + } + fn state(&self) -> std::sync::MutexGuard<'_, DecoderState> { self.shared.0.lock().unwrap_or_else(PoisonError::into_inner) } @@ -184,6 +196,10 @@ impl ScriptedDecoder { } state.creation_thread = Some(std::thread::current().id()); } + + fn take_construction_error(&self) -> Option { + self.state().construction_errors.pop_front() + } } struct WorkerDecoder(ScriptedDecoder); @@ -324,6 +340,9 @@ impl ModelFactory for ScriptedModelFactory { 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(Box::new(WorkerDecoder(decoder.clone())))) } diff --git a/crates/gateway-stt-engine/src/translation.rs b/crates/gateway-stt-engine/src/translation.rs index 13efb5ce..e3fb2c72 100644 --- a/crates/gateway-stt-engine/src/translation.rs +++ b/crates/gateway-stt-engine/src/translation.rs @@ -5,6 +5,28 @@ 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)) diff --git a/crates/gateway-stt/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index a699d0dd..3f869e07 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -9,14 +9,14 @@ target_step = "Step 30" destination = "removal after the Realtime route and Workshop relay replace the legacy socket" [modules] -"artifacts.rs" = 302 +"artifacts.rs" = 325 "audio.rs" = 397 "batch.rs" = 347 "batch/native_tests.rs" = 113 "batch/tests.rs" = 160 -"generation.rs" = 336 +"generation.rs" = 450 "generation/lease.rs" = 126 -"generation/snapshot.rs" = 98 +"generation/snapshot.rs" = 161 "lib.rs" = 41 "model.rs" = 55 "realtime/mod.rs" = 16 @@ -44,6 +44,6 @@ destination = "removal after the Realtime route and Workshop relay replace the l "take/state.rs" = 82 "take/text.rs" = 9 "test_fixtures.rs" = 420 -"test_fixtures/generation.rs" = 104 +"test_fixtures/generation.rs" = 100 "test_fixtures/native.rs" = 42 "test_fixtures/segment.rs" = 12 diff --git a/crates/gateway-stt/src/artifacts.rs b/crates/gateway-stt/src/artifacts.rs index 00d8efbd..c8770ff8 100644 --- a/crates/gateway-stt/src/artifacts.rs +++ b/crates/gateway-stt/src/artifacts.rs @@ -155,6 +155,15 @@ pub enum SpeechError { #[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")] @@ -211,6 +220,20 @@ pub enum SpeechError { } 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> { diff --git a/crates/gateway-stt/src/generation.rs b/crates/gateway-stt/src/generation.rs index e3af23f0..3ca29c0b 100644 --- a/crates/gateway-stt/src/generation.rs +++ b/crates/gateway-stt/src/generation.rs @@ -5,7 +5,7 @@ use std::sync::{Arc, PoisonError, RwLock, Weak}; use std::time::{Duration, Instant}; use gateway_stt_backend_whisper::{WhisperConfig, WhisperModelFactory}; -use gateway_stt_engine::{DecodeMode, EnginePolicy, ModelFactory, SttEngine}; +use gateway_stt_engine::{DecodeMode, EnginePolicy, ModelFactory}; use crate::artifacts::{PreparedSpeech, SpeechError}; use crate::model::{ModelNames, SpeechModelInfo}; @@ -18,7 +18,7 @@ mod snapshot; #[cfg(feature = "test-fixtures")] pub(crate) use lease::GenerationJob; pub(crate) use lease::GenerationLease; -use snapshot::{Backend, Generation}; +use snapshot::{Backend, Generation, GenerationSpec}; const GENERATION_QUIESCENCE_TIMEOUT: Duration = Duration::from_secs(30); @@ -27,6 +27,7 @@ const GENERATION_QUIESCENCE_TIMEOUT: Duration = Duration::from_secs(30); pub struct SpeechReplacement { owner: Weak, generation: Option, + rollback: Option, permit: ReplacementPermit, } @@ -60,7 +61,18 @@ impl Default for GenerationState { impl GenerationState { pub(crate) fn stage(&self, prepared: PreparedSpeech) -> Result { - self.replace_with(GENERATION_QUIESCENCE_TIMEOUT, move |id| { + 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| { @@ -77,7 +89,8 @@ impl GenerationState { prepared.interval_ms, factory.gpu_available(), ) - .map_err(SpeechError::Engine)?; + .map_err(SpeechError::Engine)? + .with_startup_timeout(startup_timeout); Generation::from_factory( id, Backend::Whisper, @@ -99,13 +112,16 @@ impl GenerationState { 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(timeout, move |id| { + self.replace_with_until(deadline, move |id, startup_timeout| { Generation::from_factory( id, Backend::Scripted, factory, - policy, + policy.with_startup_timeout(startup_timeout), ModelNames::new("scripted-interim".to_owned(), final_model), Vec::new(), ) @@ -114,22 +130,15 @@ impl GenerationState { } #[cfg(feature = "test-fixtures")] - pub(crate) fn stage_loaded_scripted( + pub(crate) fn stage_scripted_with_policy( &self, - engine: SttEngine, - interim: String, - final_model: Option, - guidance: Vec, - timeout: Duration, + factory: impl ModelFactory, + policy: EnginePolicy, ) -> Result { - self.replace_with(timeout, move |id| { - Ok(Some(Generation::from_engine( - id, - Backend::Scripted, - engine, - ModelNames::new(interim, final_model), - guidance, - ))) + self.replace_with(GENERATION_QUIESCENCE_TIMEOUT, move |id| { + GenerationSpec::scripted_inferred(factory, policy) + .build(id) + .map(Some) }) } @@ -161,13 +170,23 @@ impl GenerationState { true }); match committed { - Some(true) => {} + 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 @@ -256,24 +275,57 @@ impl GenerationState { &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(); - self.quiesce(&permit, timeout)?; + let rollback = self.quiesce(&permit, deadline)?; if !permit.is_current() { return Err(SpeechError::ReplacementInvalidated); } - let generation = build(self.next_id())?; + 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, timeout: Duration) -> Result<(), SpeechError> { + fn quiesce( + &self, + permit: &ReplacementPermit, + deadline: Instant, + ) -> Result, SpeechError> { let generation = self .shared .active @@ -282,11 +334,8 @@ impl GenerationState { .as_ref() .map(Arc::clone); let Some(generation) = generation else { - return Ok(()); + return Ok(None); }; - let deadline = Instant::now() - .checked_add(timeout) - .ok_or(SpeechError::QuiescenceDeadline)?; let close = permit .with_current(|| { let close = generation.admission.close()?; @@ -314,6 +363,7 @@ impl GenerationState { } DrainOutcome::Invalidated => Err(SpeechError::ReplacementInvalidated), DrainOutcome::Idle => { + let restart = generation.restart_spec(); let retired = permit .with_current(|| { self.shared @@ -325,7 +375,8 @@ impl GenerationState { .flatten() .ok_or(SpeechError::ReplacementInvalidated)?; drop(generation); - retired.shutdown() + retired.shutdown()?; + Ok(Some(restart)) } } } @@ -334,3 +385,66 @@ impl GenerationState { 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 active = shared + .active + .write() + .unwrap_or_else(PoisonError::into_inner); + if active.is_some() { + return false; + } + *active = Some(generation); + true + }) + .unwrap_or(false); + if !restored { + return Err(SpeechError::ReplacementInvalidated); + } + shared.changes.send_replace(id); + 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/snapshot.rs b/crates/gateway-stt/src/generation/snapshot.rs index a9ff659f..4e5d64b1 100644 --- a/crates/gateway-stt/src/generation/snapshot.rs +++ b/crates/gateway-stt/src/generation/snapshot.rs @@ -20,33 +20,93 @@ pub(super) enum Backend { } #[derive(Debug)] -pub(super) struct Generation { - pub(super) id: u64, +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, - engine: SttEngine, + factory: Arc, + policy: EnginePolicy, names: ModelNames, - pub(super) guidance: Arc<[String]>, - pub(super) admission: Arc, + guidance: Vec, + infer_scripted_final: bool, } -impl Generation { - pub(super) fn from_engine( - id: u64, +impl GenerationSpec { + pub(super) fn new( backend: Backend, - engine: SttEngine, + factory: impl ModelFactory, + policy: EnginePolicy, names: ModelNames, guidance: Vec, ) -> Self { Self { - id, 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::new("scripted-interim".to_owned(), None), + 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::new( + "scripted-interim".to_owned(), + engine.has_final_pass().then(|| "scripted-final".to_owned()), + ) + } else { + self.names.clone() + }; + Ok(Generation { + id, + backend: self.backend, engine, names, - guidance: guidance.into(), + 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, @@ -55,8 +115,11 @@ impl Generation { names: ModelNames, guidance: Vec, ) -> Result { - let engine = SttEngine::new(factory, policy).map_err(SpeechError::Engine)?; - Ok(Self::from_engine(id, backend, engine, names, guidance)) + 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> { diff --git a/crates/gateway-stt/src/service.rs b/crates/gateway-stt/src/service.rs index 97f6d9d5..4126b184 100644 --- a/crates/gateway-stt/src/service.rs +++ b/crates/gateway-stt/src/service.rs @@ -44,6 +44,18 @@ impl SpeechService { 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 @@ -52,9 +64,12 @@ impl SpeechService { self.state.commit(replacement) } - /// Drops a staged generation without publishing it. - pub fn abort_replacement(&self, replacement: SpeechReplacement) { - drop(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. @@ -86,19 +101,4 @@ impl SpeechService { pub fn workshop_routes(&self, push: workshop_server::Push) -> axum::Router { crate::stt::workshop_router(self.state.clone(), push) } - - #[cfg(feature = "test-fixtures")] - pub(crate) fn scripted_replacement( - &self, - engine: gateway_stt_engine::SttEngine, - final_model: Option, - ) -> Result { - self.state.stage_loaded_scripted( - engine, - "scripted-interim".to_owned(), - final_model, - Vec::new(), - std::time::Duration::from_secs(30), - ) - } } diff --git a/crates/gateway-stt/src/test_fixtures/generation.rs b/crates/gateway-stt/src/test_fixtures/generation.rs index 38bd676e..0df0775d 100644 --- a/crates/gateway-stt/src/test_fixtures/generation.rs +++ b/crates/gateway-stt/src/test_fixtures/generation.rs @@ -2,8 +2,6 @@ use std::time::Duration; -use gateway_stt_engine::{EnginePolicy, SttEngine}; - use crate::generation::{GenerationJob, GenerationLease}; use crate::{SpeechError, SpeechService}; @@ -19,12 +17,10 @@ pub fn scripted_service( interval_ms: u64, ) -> Result { let gpu_available = factory.gpu_available(); - let policy = EnginePolicy::new(window_seconds, interval_ms, gpu_available) - .map_err(SpeechError::Engine)?; - let engine = SttEngine::new(factory, policy).map_err(SpeechError::Engine)?; - let final_name = engine.has_final_pass().then(|| "scripted-final".to_owned()); let service = SpeechService::new(); - let replacement = service.scripted_replacement(engine, final_name)?; + 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) } diff --git a/crates/gateway-stt/tests/it/architecture.rs b/crates/gateway-stt/tests/it/architecture.rs index 0299833c..3777722a 100644 --- a/crates/gateway-stt/tests/it/architecture.rs +++ b/crates/gateway-stt/tests/it/architecture.rs @@ -656,6 +656,42 @@ fn generation_quiescence_uses_explicit_ownership_without_item_transfer() { ); } +#[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 persistence = read(&crate_root("gateway").join("src/config_write.rs")); + + for policy in [ + "rollback: Option", + "restore_generation", + "impl Drop for SpeechReplacement", + ] { + assert!( + generation.contains(policy), + "speech replacement must retain {policy}" + ); + } + for policy in [ + "PreparedPersistence::prepare", + "PersistenceCommitError::Determinate", + "PersistenceCommitError::Indeterminate", + "PROFILE_STAGE_TIMEOUT", + "state.shutdown.fire()", + ] { + assert!( + gateway.contains(policy), + "Gateway transaction policy must retain {policy}" + ); + } + for policy in ["file.sync_all()", "std::fs::rename", "sync_parent"] { + assert!( + persistence.contains(policy), + "profile persistence must retain {policy}" + ); + } +} + #[test] fn compiler_unsafe_lints_cover_the_stt_stack() { let workspace: toml::Value = toml::from_str(&read(&workspace_root().join("Cargo.toml"))) diff --git a/crates/gateway-stt/tests/it/generation.rs b/crates/gateway-stt/tests/it/generation.rs index 436887aa..00416de7 100644 --- a/crates/gateway-stt/tests/it/generation.rs +++ b/crates/gateway-stt/tests/it/generation.rs @@ -140,7 +140,9 @@ fn replacement_is_serial_and_publishes_one_complete_snapshot() { ); assert!(second_interim.creation_thread().is_none()); - service.abort_replacement(first); + service + .abort_replacement(first) + .expect("aborting the first replacement leaves no old generation"); let second = finished_rx .recv_timeout(WAIT) .expect("second replacement resumes") @@ -249,6 +251,118 @@ async fn active_replacement_drains_request_and_job_before_unload_and_publication 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(); diff --git a/crates/gateway/src/config_apply.rs b/crates/gateway/src/config_apply.rs index feffd0f4..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( 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/config_write.rs b/crates/gateway/src/config_write.rs index 219b500d..4ff8f7f2 100644 --- a/crates/gateway/src/config_write.rs +++ b/crates/gateway/src/config_write.rs @@ -9,6 +9,10 @@ //! shadow mechanics live in `gateway-config`; these handlers //! own auth, path resolution, and the JSON-to-TOML boundary. +use std::io::Write as _; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + use axum::Json; use axum::extract::State; use axum::extract::rejection::JsonRejection; @@ -18,6 +22,106 @@ use crate::auth::Caller; use crate::error::GatewayError; use crate::{AppState, check_auth}; +static PERSISTENCE_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +/// One fully written and synced temporary file awaiting atomic replacement. +#[derive(Debug)] +pub(crate) struct PreparedFile { + target: PathBuf, + temporary: PathBuf, + original: Option>, + contents: Vec, +} + +impl PreparedFile { + pub(crate) fn prepare(target: PathBuf, contents: String) -> Result { + let temporary = persistence_temporary(&target); + 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 = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + .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, + original, + contents: contents.into_bytes(), + }) + } + + pub(crate) fn commit(&mut self) -> Result<(), std::io::Error> { + std::fs::rename(&self.temporary, &self.target) + } + + pub(crate) 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, + } + } + + pub(crate) fn has_committed_contents(&self) -> bool { + std::fs::read(&self.target).is_ok_and(|current| current == self.contents) + } + + pub(crate) fn target(&self) -> &Path { + &self.target + } + + #[cfg(test)] + pub(crate) fn discard_temporary(&self) { + std::fs::remove_file(&self.temporary).expect("prepared temporary exists"); + } +} + +impl Drop for PreparedFile { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.temporary); + } +} + +fn persistence_temporary(target: &Path) -> PathBuf { + let mut name = target + .file_name() + .map_or_else(|| "profile".into(), std::ffi::OsStr::to_os_string); + name.push(format!( + ".prepared-{}-{}", + std::process::id(), + PERSISTENCE_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + 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" +)] +pub(crate) 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(()) + } +} + /// The `PUT /admin/config` route: bearer-authed, stages the global config /// and optional sibling profile state. /// diff --git a/crates/gateway/src/lib.rs b/crates/gateway/src/lib.rs index 02258ba7..f3f59472 100644 --- a/crates/gateway/src/lib.rs +++ b/crates/gateway/src/lib.rs @@ -247,9 +247,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. @@ -294,7 +295,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, @@ -302,6 +303,8 @@ pub(crate) mod switch_park { Spawn, /// The commit, once the switch lock is held again. Commit, + /// The persistence-to-live-publication boundary. + Publish, } /// Parks the switch at `phase` until the test releases it. Single use: @@ -1334,6 +1337,159 @@ pub(crate) enum StatePersistence { Promote(Vec), } +const PROFILE_STAGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +struct PreparedPersistence { + files: Vec, + captures: Vec, +} + +enum PersistenceCommitError { + Determinate(GatewayError), + Indeterminate(GatewayError), +} + +struct CutoverState { + 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, +} + +#[derive(Debug)] +enum CommitFailure { + Determinate(GatewayError), + Fatal(GatewayError), +} + +#[derive(Debug)] +#[cfg_attr( + not(any(feature = "local", feature = "stt")), + expect( + dead_code, + reason = "the featureless stage stub cannot produce either runtime failure classification" + ) +)] +enum RuntimeStageFailure { + Determinate(GatewayError), + Fatal(GatewayError), +} + +#[cfg(feature = "stt")] +fn classify_speech_stage_failure(error: gateway_stt::SpeechError) -> RuntimeStageFailure { + let fatal = error.is_non_preemptible_startup_timeout(); + let error = GatewayError::switch_failed("start-stt", error); + if fatal { + RuntimeStageFailure::Fatal(error) + } else { + RuntimeStageFailure::Determinate(error) + } +} + +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(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)| config_write::PreparedFile::prepare(target, contents)) + .collect::, _>>() + }) + .await + .map_err(|join| GatewayError::ConfigWriteIo(Box::new(join)))??; + Ok(Self { files, captures }) + } + + 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(config_write::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", + ))), + )); + } + config_write::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(()) + } +} + /// Executes a switch using an optional catalog parsed by Apply. /// /// The switch runs in five phases and holds the `switch` lock - the one @@ -1342,23 +1498,24 @@ 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 runtimes and the active speech generation stop under a +/// 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`. /// @@ -1371,22 +1528,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, @@ -1410,10 +1564,7 @@ async fn run_switch_with_config( 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); - } + Err(error) => return Err(error), }; #[cfg(feature = "local")] @@ -1433,53 +1584,146 @@ async fn run_switch_with_config( /// 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( +async fn prepare_cutover( state: &AppState, name: &ProfileName, tree: &ProgressTree, - target: SwitchTarget, - persistence: impl FnOnce() -> StatePersistence, + target: &SwitchTarget, + stop: StopSet, + persistence: StatePersistence, token: &tokio_util::sync::CancellationToken, -) -> Result { - let stop = stop_set(state).await; +) -> Result<(PreparedPersistence, CutoverState), GatewayError> { if stop.is_empty() { - cut_over(state, &target, tree, stop, token).await?; - #[cfg(test)] - { - state.park_at(switch_park::SwitchPhase::Download).await; + let prepared = PreparedPersistence::prepare(state, name, persistence).await?; + if token.is_cancelled() { + return Err(switch_cancelled(name)); + } + let old = capture_cutover_state(state).await; + if let Err(error) = cut_over(state, target, tree, stop, token).await { + return Err(restore_or_shutdown(state, token, old, error).await); } - download_artifacts(&target, tree, token).await?; - } else { #[cfg(test)] - { - state.park_at(switch_park::SwitchPhase::Download).await; + state.park_at(switch_park::SwitchPhase::Download).await; + if let Err(error) = download_artifacts(target, tree, token).await { + return Err(restore_or_shutdown(state, token, old, error).await); } - download_artifacts(&target, tree, token).await?; - cut_over(state, &target, tree, stop, token).await?; + return Ok((prepared, old)); } - // Phase boundary: start no replacement children for a cancelled command. + + #[cfg(test)] + state.park_at(switch_park::SwitchPhase::Download).await; + download_artifacts(target, tree, token).await?; + if token.is_cancelled() { + return Err(switch_cancelled(name)); + } + let prepared = PreparedPersistence::prepare(state, name, persistence).await?; if token.is_cancelled() { return Err(switch_cancelled(name)); } + let old = capture_cutover_state(state).await; + if let Err(error) = cut_over(state, target, tree, stop, token).await { + return Err(restore_or_shutdown(state, token, old, error).await); + } + Ok((prepared, old)) +} + +async fn run_switch_phases( + state: &AppState, + name: &ProfileName, + tree: &ProgressTree, + target: SwitchTarget, + persistence: impl FnOnce() -> StatePersistence, + token: &tokio_util::sync::CancellationToken, +) -> Result { + #[cfg(feature = "stt")] + let mut target = target; + #[cfg(not(feature = "stt"))] + let target = target; + let stop = stop_set(state).await; + let (prepared_persistence, old) = + prepare_cutover(state, name, tree, &target, stop, persistence(), token).await?; + // Phase boundary: start no replacement children for a cancelled command. + if token.is_cancelled() { + return Err(restore_or_shutdown(state, token, old, switch_cancelled(name)).await); + } #[cfg(test)] { state.park_at(switch_park::SwitchPhase::Spawn).await; } - let replacement = spawn_runtimes( + #[cfg(feature = "stt")] + let Some(prepared_speech) = target.speech.take() else { + let error = GatewayError::switch_failed( + "stage-stt", + std::io::Error::other("speech preparation was already consumed"), + ); + return Err(restore_or_shutdown(state, token, old, error).await); + }; + let deadline = std::time::Instant::now() + .checked_add(PROFILE_STAGE_TIMEOUT) + .ok_or_else(|| { + GatewayError::switch_failed( + "stage-profile-deadline", + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "profile staging deadline could not be represented", + ), + ) + })?; + let replacement = match spawn_runtimes( &target.config, #[cfg(feature = "stt")] state.speech.clone(), + #[cfg(feature = "stt")] + prepared_speech, tree, token, + deadline, ) - .await?; + .await + { + Ok(replacement) => replacement, + Err(RuntimeStageFailure::Determinate(error)) => { + return Err(restore_or_shutdown(state, token, old, error).await); + } + Err(RuntimeStageFailure::Fatal(error)) => { + return Err(request_fatal_shutdown( + state, + token, + "stage-profile-timeout", + error, + )); + } + }; // 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)); + if let Err(rollback) = rollback_runtime(state, replacement) { + return Err(request_fatal_shutdown( + state, + token, + "rollback-staged-profile", + rollback, + )); + } + return Err(restore_or_shutdown(state, token, old, switch_cancelled(name)).await); + } + match commit_switch( + state, + name, + target, + replacement, + prepared_persistence, + token, + ) + .await + { + Ok(report) => Ok(report), + Err(CommitFailure::Determinate(error)) => { + Err(restore_or_shutdown(state, token, old, error).await) + } + Err(CommitFailure::Fatal(error)) => Err(error), } - commit_switch(state, name, target, replacement, persistence(), token).await } /// The cancellation a switch reports when its token fires at a phase @@ -1501,6 +1745,8 @@ struct SwitchTarget { /// The local models the spawn will start, published as /// [`LiveState::loading`] at cut-over. loading: BTreeSet, + #[cfg(feature = "stt")] + speech: Option, } /// Phase 1: resolves the target profile from the catalog, unlocked. @@ -1530,6 +1776,16 @@ async fn prepare_switch( 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")] @@ -1552,6 +1808,8 @@ async fn prepare_switch( web_search, allowlist, loading, + #[cfg(feature = "stt")] + speech: Some(speech), }) } @@ -1709,8 +1967,6 @@ async fn cut_over( Some(OldRuntimes { #[cfg(feature = "local")] local: std::mem::replace(&mut live.local, LocalRuntime::empty()), - #[cfg(feature = "stt")] - speech: stop.stt.then(|| state.speech.clone()), }) } }; @@ -1741,18 +1997,12 @@ async fn cut_over( struct OldRuntimes { #[cfg(feature = "local")] local: LocalRuntime, - #[cfg(feature = "stt")] - speech: 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(speech) = self.speech { - speech.shutdown(); - } #[cfg(feature = "local")] let result = self.local.shutdown(); #[cfg(not(feature = "local"))] @@ -1761,11 +2011,135 @@ impl OldRuntimes { } } -/// 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(); +async fn capture_cutover_state(state: &AppState) -> CutoverState { + let live = state.live.read().await; + CutoverState { + 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_cutover_state(state: &AppState, old: CutoverState) -> Result<(), GatewayError> { + #[cfg(feature = "local")] + let local = if old.restart_local { + let config = Arc::clone(&old.config); + tokio::time::timeout( + PROFILE_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 !old.routing_was_empty { + live.routing = old.routing; + } + live.config = old.config; + #[cfg(feature = "web-search")] + { + live.web_search = old.web_search; + } + live.profile_name = old.profile_name; + live.model_allowlist = old.model_allowlist; + live.loading = old.loading; + #[cfg(feature = "local")] + { + live.local = local; + } + Ok(()) +} + +async fn restore_or_shutdown( + state: &AppState, + token: &tokio_util::sync::CancellationToken, + old: CutoverState, + failure: GatewayError, +) -> GatewayError { + match restore_cutover_state(state, old).await { + Ok(()) => failure, + Err(rollback) => { + token.cancel(); + state.shutdown.fire(); + #[cfg(feature = "stt")] + state.speech.shutdown(); + GatewayError::switch_failed("rollback-profile", rollback) + } + } +} + +#[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(()) +} + +fn request_fatal_shutdown( + state: &AppState, + token: &tokio_util::sync::CancellationToken, + phase: &'static str, + error: GatewayError, +) -> GatewayError { + token.cancel(); + state.shutdown.fire(); + #[cfg(feature = "stt")] + state.speech.shutdown(); + GatewayError::switch_failed(phase, error) +} + +fn rollback_commit_failure( + state: &AppState, + token: &tokio_util::sync::CancellationToken, + replacement: RuntimeReplacement, + failure: GatewayError, +) -> CommitFailure { + match rollback_runtime(state, replacement) { + Ok(()) => CommitFailure::Determinate(failure), + Err(rollback) => CommitFailure::Fatal(request_fatal_shutdown( + state, + token, + "rollback-staged-profile", + GatewayError::switch_failed( + "determinate-profile-failure", + std::io::Error::other(format!( + "{}; {}", + config_write::error_chain(&failure), + config_write::error_chain(&rollback) + )), + ), + )), + } } /// Phase 5: the commit, under the switch lock. Merges the started local @@ -1779,46 +2153,83 @@ async fn commit_switch( name: &ProfileName, target: SwitchTarget, replacement: RuntimeReplacement, - persistence: StatePersistence, + persistence: PreparedPersistence, token: &tokio_util::sync::CancellationToken, -) -> Result { +) -> Result { let _switch = state.switch.lock().await; #[cfg(test)] { state.park_at(switch_park::SwitchPhase::Commit).await; } #[cfg(feature = "local")] - let routing = target + let routing = match target .remote_routing .merge(replacement.local.models().iter().cloned()) - .map_err(|e| GatewayError::switch_failed("merge-routing", e))?; + { + Ok(routing) => routing, + Err(error) => { + let failure = GatewayError::switch_failed("merge-routing", error); + return Err(rollback_commit_failure(state, token, replacement, failure)); + } + }; #[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 = "stt")] - state - .speech - .commit_replacement(replacement.speech) - .map_err(|error| GatewayError::switch_failed("publish-stt", error))?; - - #[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(), + if token.is_cancelled() { + return Err(rollback_commit_failure( + state, + token, + replacement, + GatewayError::CommandCancelled("profile switch".to_owned()), + )); + } + let _publication = tokio::select! { + biased; + () = token.cancelled() => { + return Err(rollback_commit_failure( + state, + token, + replacement, + GatewayError::CommandCancelled("profile switch".to_owned()), + )); + } + guard = state.apply.lock() => guard, }; - #[cfg(not(feature = "local"))] - let report = StartReport {}; + if token.is_cancelled() { + return Err(rollback_commit_failure( + state, + token, + replacement, + GatewayError::CommandCancelled("profile switch".to_owned()), + )); + } + match persistence.commit().await { + Ok(()) => {} + Err(PersistenceCommitError::Determinate(error)) => { + return Err(rollback_commit_failure(state, token, replacement, error)); + } + Err(PersistenceCommitError::Indeterminate(error)) => { + return Err(CommitFailure::Fatal(request_fatal_shutdown( + state, + token, + "persist-profile-indeterminate", + error, + ))); + } + } + #[cfg(test)] + { + state.park_at(switch_park::SwitchPhase::Publish).await; + } + let report = start_report(&replacement); + #[cfg(feature = "stt")] + if let Err(error) = state.speech.commit_replacement(replacement.speech) { + return Err(CommitFailure::Fatal(request_fatal_shutdown( + state, + token, + "publish-stt", + GatewayError::switch_failed("publish-stt", error), + ))); + } // Atomic swap: commit the whole new profile at once. let mut live = state.live.write().await; @@ -1841,9 +2252,32 @@ async fn commit_switch( Ok(report) } +#[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 {} +} + /// What the spawn reported once the commit landed: the local models that /// reached readiness and the ones that failed, rendered for /// [`GatewayError::PartialStart`]. +#[derive(Debug)] struct StartReport { #[cfg(feature = "local")] loaded: Vec, @@ -1918,7 +2352,8 @@ async fn spawn_runtimes( _config: &Config, _tree: &ProgressTree, _token: &tokio_util::sync::CancellationToken, -) -> Result { + _deadline: std::time::Instant, +) -> Result { Ok(RuntimeReplacement {}) } @@ -1930,9 +2365,11 @@ async fn spawn_runtimes( async fn spawn_runtimes( config: &Config, #[cfg(feature = "stt")] speech: SpeechService, + #[cfg(feature = "stt")] prepared_speech: gateway_stt::PreparedSpeech, tree: &ProgressTree, token: &tokio_util::sync::CancellationToken, -) -> Result { + deadline: std::time::Instant, +) -> Result { let starting = tree.register("starting-models", 5.0); #[cfg(feature = "local")] let start_config = config.clone(); @@ -1953,25 +2390,42 @@ async fn spawn_runtimes( 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, - ) - }) + let result = tokio::time::timeout( + deadline.saturating_duration_since(std::time::Instant::now()), + 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(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(GatewayError::switch_failed("start-local", error)); + return Err(RuntimeStageFailure::Determinate( + GatewayError::switch_failed("start-local-task", error), + )); } - Err(error) => { + Err(_) => { starting.fail(); - return Err(GatewayError::switch_failed("start-local-task", error)); + return Err(RuntimeStageFailure::Fatal(GatewayError::switch_failed( + "start-local-timeout", + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "local runtime startup exceeded the shared profile deadline", + ), + ))); } } }; @@ -1982,27 +2436,26 @@ async fn spawn_runtimes( // children. #[cfg(feature = "stt")] if token.is_cancelled() { - return Err(GatewayError::CommandCancelled("profile switch".to_owned())); + return Err(RuntimeStageFailure::Determinate( + GatewayError::CommandCancelled("profile switch".to_owned()), + )); } #[cfg(feature = "stt")] - let speech_config = config.clone(); - #[cfg(feature = "stt")] - let stt_progress = starting.clone(); - #[cfg(feature = "stt")] let speech = match tokio::task::spawn_blocking(move || { - let prepared = speech.prepare(&speech_config, Some(&stt_progress))?; - speech.begin_replacement(prepared) + speech.begin_replacement_before(prepared_speech, deadline) }) .await { Ok(Ok(runtime)) => runtime, Ok(Err(error)) => { starting.fail(); - return Err(GatewayError::switch_failed("start-stt", error)); + return Err(classify_speech_stage_failure(error)); } Err(error) => { starting.fail(); - return Err(GatewayError::switch_failed("start-stt-task", error)); + return Err(RuntimeStageFailure::Determinate( + GatewayError::switch_failed("start-stt-task", error), + )); } }; #[cfg(feature = "local")] @@ -2023,50 +2476,6 @@ async fn spawn_runtimes( }) } -/// 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 @@ -2530,6 +2939,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 _; @@ -2698,9 +3111,8 @@ mod provisioning_tests { /// 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\ + 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\ @@ -2709,9 +3121,14 @@ mod provisioning_tests { [[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"); + [[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) } @@ -2789,6 +3206,437 @@ 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" + ); + } + } + + #[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 = crate::config_write::PreparedFile::prepare( + target.clone(), + "active_profile = \"beta\"\n".to_owned(), + ) + .expect("prepare determinate fixture"); + determinate.discard_temporary(); + let error = crate::PreparedPersistence { + files: vec![determinate], + captures: Vec::new(), + } + .commit_blocking() + .expect_err("missing temporary prevents commit"); + assert!(matches!( + error, + crate::PersistenceCommitError::Determinate(_) + )); + + let indeterminate = crate::config_write::PreparedFile::prepare( + target.clone(), + "active_profile = \"beta\"\n".to_owned(), + ) + .expect("prepare indeterminate fixture"); + std::fs::write(&target, "unrecognized contents").expect("replace authoritative state"); + indeterminate.discard_temporary(); + let error = crate::PreparedPersistence { + files: vec![indeterminate], + captures: Vec::new(), + } + .commit_blocking() + .expect_err("missing temporary prevents commit"); + assert!(matches!( + error, + crate::PersistenceCommitError::Indeterminate(_) + )); + } + + #[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 prepared = crate::config_write::PreparedFile::prepare( + target_path, + "active_profile = \"beta\"\n".to_owned(), + ) + .expect("prepare state"); + prepared.discard_temporary(); + let persistence = crate::PreparedPersistence { + files: vec![prepared], + captures: Vec::new(), + }; + let name = ProfileName::parse("beta").expect("profile name"); + let tree = state.hub.operation(); + let target = crate::prepare_switch(&state, &name, &tree, None) + .await + .expect("target prepares"); + let replacement = crate::RuntimeReplacement { + #[cfg(feature = "local")] + local: crate::local::LocalRuntime::empty(), + #[cfg(feature = "local")] + start_failures: Vec::new(), + speech, + }; + let token = CancellationToken::new(); + + let error = crate::commit_switch(&state, &name, target, replacement, persistence, &token) + .await + .expect_err("failed rollback makes a determinate persistence failure fatal"); + let crate::CommitFailure::Fatal(error) = error else { + panic!("failed rollback must be 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 prepared = crate::config_write::PreparedFile::prepare( + 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"); + prepared.discard_temporary(); + let persistence = crate::PreparedPersistence { + files: vec![prepared], + captures: Vec::new(), + }; + let name = ProfileName::parse("beta").expect("profile name"); + let tree = state.hub.operation(); + let target = crate::prepare_switch(&state, &name, &tree, None) + .await + .expect("target prepares"); + let replacement = crate::RuntimeReplacement { + #[cfg(feature = "local")] + local: crate::local::LocalRuntime::empty(), + #[cfg(feature = "local")] + start_failures: Vec::new(), + speech, + }; + let token = CancellationToken::new(); + + let error = crate::commit_switch(&state, &name, target, replacement, persistence, &token) + .await + .expect_err("indeterminate persistence is fatal"); + assert!(matches!(error, crate::CommitFailure::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::PreparedPersistence { + files: vec![ + crate::config_write::PreparedFile::prepare( + state_path.clone(), + "active_profile = \"beta\"\n".to_owned(), + ) + .expect("prepare state"), + ], + captures: Vec::new(), + }; + let name = ProfileName::parse("beta").expect("profile name"); + let tree = state.hub.operation(); + let target = crate::prepare_switch(&state, &name, &tree, None) + .await + .expect("target prepares"); + let replacement = crate::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::commit_switch( + &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 dirty_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 + }); + 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" + ); + + 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"); + assert_eq!(reply["profile"]["active_profile"], "beta"); + assert_eq!(dirty["dirty"], false); + 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(); + next.park_construction(); + let replacement_service = service.clone(); + let next_factory = ScriptedModelFactory::new(next.clone()); + let (result_tx, result_rx) = std::sync::mpsc::channel(); + let constructor = std::thread::spawn(move || { + let result = begin_scripted_replacement( + &replacement_service, + next_factory, + false, + Duration::from_millis(20), + ); + drop(result_tx.send(result)); + }); + assert!(next.wait_until_construction_parked(Duration::from_secs(1))); + let error = result_rx + .recv_timeout(Duration::from_secs(1)) + .expect("startup returns at the shared deadline") + .expect_err("parked native-equivalent startup times out"); + let crate::RuntimeStageFailure::Fatal(error) = crate::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::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()); + next.release_construction(); + constructor.join().expect("constructor thread joins"); + 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::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. 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/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/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index b0a9529e..be8a85ad 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -573,7 +573,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 483b7d09..773d970c 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -146,8 +146,8 @@ N17 | observation | Violates A2 @ crates/gateway-stt/tests/it/architecture.rs: n 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 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 -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 -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 +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 +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 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 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 @@ -161,6 +161,14 @@ N32 | observation | oversized-unit @ crates/gateway-stt/src/realtime/wire/server 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 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 -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 -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 +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 +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 +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 +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 +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 +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 +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 +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 +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 From 909ffea2a59d570b75bade9775eb68a09df18a89 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 14:20:18 -0700 Subject: [PATCH 30/86] Separate Gateway and Workshop origin policies Define distinct fail-closed origin predicates for Gateway and Workshop WebSocket handshakes. Gateway accepts native clients and exact HTTP loopback origins, while Workshop requires a valid request authority and normalized host and port agreement. Keep the policies separate so later route mounting cannot merge the products' trust boundaries. - `gateway_loopback_origin_allowed` exposes the Gateway policy as a public pure predicate. It rejects secure, foreign, path-bearing, query-bearing, and malformed browser origins. - `workshop_same_origin_authority_allowed` exposes the Workshop policy as a public pure predicate. Native clients still need a valid request authority, and browser clients must match its normalized host and explicit port. - `parse_authority` validates bracketed and unbracketed authorities before either product policy uses them. - `crates/shared-loopback/src/lib.rs` adds predicate-only policy and does not mount middleware or routes. Design: new surface-growth @ crates/shared-loopback/src/lib.rs::gateway_loopback_origin_allowed deps: Option<&str> boundary: pub Design: new pure-function @ crates/shared-loopback/src/lib.rs::gateway_loopback_origin_allowed deps: Option<&str> boundary: pub Design: new surface-growth @ crates/shared-loopback/src/lib.rs::workshop_same_origin_authority_allowed deps: Option<&str>,Option<&str> boundary: pub Design: new pure-function @ crates/shared-loopback/src/lib.rs::workshop_same_origin_authority_allowed deps: Option<&str>,Option<&str> boundary: pub Design: new pure-function @ crates/shared-loopback/src/lib.rs::same_origin_authority deps: &Authority,&Authority Design: new pure-function @ crates/shared-loopback/src/lib.rs::same_authority_host deps: &str,&str Design: new pure-function @ crates/shared-loopback/src/lib.rs::parse_http_origin_authority deps: &str Design: new pure-function @ crates/shared-loopback/src/lib.rs::parse_authority deps: &str Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- crates/shared-loopback/AGENTS.md | 5 +- crates/shared-loopback/src/lib.rs | 192 +++++++++++++++++++++- vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- 3 files changed, 195 insertions(+), 4 deletions(-) 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/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index be8a85ad..77845b4d 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -584,7 +584,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. From 6016500605a178a02c5a958a23cc7e1e01da077e Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 14:56:43 -0700 Subject: [PATCH 31/86] Publish generic speech discovery facts Expose one coherent speech snapshot through operational status and model discovery so callers see only active capabilities. Reserve the logical realtime identity for paired workers and preserve configured state while replacement closes admission. - `Publication` keeps active generation and configured state under one lock, while discovery readers take the profile publication lock before combining routing and speech facts. - `CatalogModelInfo` preserves inference metadata and adds a separate transcription wire shape. `module-ceilings.toml` updates exact size ratchets for the changed speech modules. - `REALTIME_TRANSCRIBE_MODEL` cannot name a physical worker and appears only when interim and final workers are both active. `SpeechSnapshot` reports configured, ready, GPU, and generation facts. - `list_models` omits inactive speech models, and `featureless_gateway_omits_speech_status_and_models` pins omission when speech support is not compiled. Design: extends surface-growth @ crates/gateway-stt/src/artifacts.rs::SpeechError boundary: pub Design: extends shared-mutable-state @ crates/gateway-stt/src/generation.rs::GenerationState Design: extends oversized-unit @ crates/gateway-stt/src/generation.rs Design: new newtype @ crates/gateway-stt/src/model.rs::ReservedLogicalModelName Design: new encapsulated-invariant @ crates/gateway-stt/src/model.rs::ModelNames::new Design: new surface-growth @ crates/gateway-stt/src/model.rs::ModelNames::infos boundary: pub Design: extends facade @ crates/gateway-stt/src/service.rs::SpeechService boundary: pub Design: new pure-function @ crates/gateway-stt/tests/it/service.rs::selected_speech_config deps: &str,&str Design: new pure-function @ crates/gateway/src/lib.rs::with_speech_endpoint deps: Vec,bool,gateway_stt::SpeechStatus Design: new surface-growth @ crates/gateway/src/lib.rs::list_models deps: Caller,State boundary: wire Design: new surface-growth @ crates/gateway/src/lib.rs::admin_status deps: Caller,State boundary: wire Design: new surface-growth @ crates/gateway/src/model_info.rs::CatalogModelsResponse boundary: wire Design: new surface-growth @ crates/gateway/src/model_info.rs::CatalogModelInfo boundary: wire Design: new surface-growth @ crates/gateway/src/model_info.rs::SpeechCatalogModelInfo boundary: wire Design: new surface-growth @ crates/gateway/src/system.rs::SpeechSnapshot boundary: wire Violates: A2 - credential ownership in SpeechService is not determinable from diff Pending: N5 - compounds Pending: N6 - compounds Pending: N24 - compounds Pending: N25 - compounds Pending: N36 - compounds Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- README.md | 2 +- crates/gateway-stt/module-ceilings.toml | 8 +- crates/gateway-stt/src/artifacts.rs | 25 +++- crates/gateway-stt/src/generation.rs | 71 ++++++---- crates/gateway-stt/src/generation/snapshot.rs | 7 +- crates/gateway-stt/src/model.rs | 62 ++++++++- crates/gateway-stt/src/service.rs | 2 +- crates/gateway-stt/src/status.rs | 4 +- crates/gateway-stt/tests/it/architecture.rs | 46 +++++++ crates/gateway-stt/tests/it/generation.rs | 20 ++- crates/gateway-stt/tests/it/service.rs | 64 ++++++++- crates/gateway/src/lib.rs | 124 ++++++++++++++---- crates/gateway/src/model_info.rs | 100 +++++++++++++- crates/gateway/src/system.rs | 38 ++++++ crates/gateway/src/test_support.rs | 63 +++++++++ crates/gateway/tests/it/surface.rs | 89 +++++++++++++ vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- vibe/archdoc-next.md | 10 +- 18 files changed, 651 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index a24e0c00..15b168ef 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. diff --git a/crates/gateway-stt/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index 3f869e07..31238e0f 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -9,16 +9,16 @@ target_step = "Step 30" destination = "removal after the Realtime route and Workshop relay replace the legacy socket" [modules] -"artifacts.rs" = 325 +"artifacts.rs" = 346 "audio.rs" = 397 "batch.rs" = 347 "batch/native_tests.rs" = 113 "batch/tests.rs" = 160 -"generation.rs" = 450 +"generation.rs" = 467 "generation/lease.rs" = 126 -"generation/snapshot.rs" = 161 +"generation/snapshot.rs" = 158 "lib.rs" = 41 -"model.rs" = 55 +"model.rs" = 105 "realtime/mod.rs" = 16 "realtime/input.rs" = 195 "realtime/item.rs" = 157 diff --git a/crates/gateway-stt/src/artifacts.rs b/crates/gateway-stt/src/artifacts.rs index c8770ff8..493bcb59 100644 --- a/crates/gateway-stt/src/artifacts.rs +++ b/crates/gateway-stt/src/artifacts.rs @@ -6,7 +6,7 @@ use gateway_config::{Config, SttRole}; use gateway_local::artifacts::ArtifactStore; use shared_progress::ProgressHandle; -use crate::model::ModelNames; +use crate::model::{ModelNames, REALTIME_TRANSCRIBE_MODEL}; /// Verified artifacts and policy for a generation that has not started workers. #[derive(Debug)] @@ -39,6 +39,15 @@ pub(crate) fn prepare( 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)?; @@ -61,7 +70,11 @@ pub(crate) fn prepare( library, interim_model, final_model, - names: ModelNames::new(interim_name, final_name), + 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(), @@ -126,6 +139,14 @@ pub enum SpeechError { #[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")] diff --git a/crates/gateway-stt/src/generation.rs b/crates/gateway-stt/src/generation.rs index 3ca29c0b..cd0ba33c 100644 --- a/crates/gateway-stt/src/generation.rs +++ b/crates/gateway-stt/src/generation.rs @@ -33,12 +33,18 @@ pub struct SpeechReplacement { #[derive(Debug)] struct Shared { - active: RwLock>>, + publication: RwLock, next_generation: AtomicU64, changes: tokio::sync::watch::Sender, 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 { @@ -50,7 +56,7 @@ impl Default for GenerationState { let (changes, _receiver) = tokio::sync::watch::channel(0); Self { shared: Arc::new(Shared { - active: RwLock::new(None), + publication: RwLock::new(Publication::default()), next_generation: AtomicU64::new(1), changes, replacements: Arc::new(ReplacementCoordinator::default()), @@ -122,7 +128,7 @@ impl GenerationState { Backend::Scripted, factory, policy.with_startup_timeout(startup_timeout), - ModelNames::new("scripted-interim".to_owned(), final_model), + ModelNames::scripted(final_model.is_some()), Vec::new(), ) .map(Some) @@ -152,20 +158,21 @@ impl GenerationState { let mut replacement = replacement; let published = replacement.generation.take().map(Arc::new); + let configured = published.is_some(); let revision = published .as_ref() .map_or_else(|| self.next_id(), |generation| generation.id); let committed = replacement.permit.with_current(|| { - let mut active = self + let mut publication = self .shared - .active + .publication .write() .unwrap_or_else(PoisonError::into_inner); - if active.is_some() { + if publication.active.is_some() { return false; } - *active = published; - drop(active); + publication.active = published; + publication.configured = configured; self.shared.changes.send_replace(revision); true }); @@ -191,9 +198,10 @@ impl GenerationState { let _shutdown = self.shared.replacements.begin_shutdown(); let generation = self .shared - .active + .publication .read() .unwrap_or_else(PoisonError::into_inner) + .active .as_ref() .map(Arc::clone); let Some(generation) = generation else { @@ -204,9 +212,10 @@ impl GenerationState { generation.admission.wait_until_idle(); let retired = self .shared - .active + .publication .write() .unwrap_or_else(PoisonError::into_inner) + .active .take_if(|active| Arc::ptr_eq(active, &generation)); drop(generation); if let Some(retired) = retired @@ -217,12 +226,12 @@ impl GenerationState { } pub(crate) fn active(&self) -> Option { - let active = self + let publication = self .shared - .active + .publication .read() .unwrap_or_else(PoisonError::into_inner); - let generation = active.as_ref()?; + let generation = publication.active.as_ref()?; let admission = generation.admission.admit()?; Some(GenerationLease::new(Arc::clone(generation), admission)) } @@ -238,24 +247,29 @@ impl GenerationState { } pub(crate) fn status(&self) -> SpeechStatus { - let active = self + let publication = self .shared - .active + .publication .read() .unwrap_or_else(PoisonError::into_inner); - active + publication + .active .as_deref() .filter(|generation| generation.admission.is_open()) - .map_or_else(SpeechStatus::inactive, Generation::status) + .map_or_else( + || SpeechStatus::unready(publication.configured), + Generation::status, + ) } pub(crate) fn models(&self) -> Vec { - let active = self + let publication = self .shared - .active + .publication .read() .unwrap_or_else(PoisonError::into_inner); - active + publication + .active .as_deref() .filter(|generation| generation.admission.is_open()) .map_or_else(Vec::new, Generation::models) @@ -264,9 +278,10 @@ impl GenerationState { #[cfg(feature = "test-fixtures")] pub(crate) fn counts(&self) -> Option<(usize, usize)> { self.shared - .active + .publication .read() .unwrap_or_else(PoisonError::into_inner) + .active .as_ref() .map(|generation| generation.admission.counts()) } @@ -328,9 +343,10 @@ impl GenerationState { ) -> Result, SpeechError> { let generation = self .shared - .active + .publication .read() .unwrap_or_else(PoisonError::into_inner) + .active .as_ref() .map(Arc::clone); let Some(generation) = generation else { @@ -367,9 +383,10 @@ impl GenerationState { let retired = permit .with_current(|| { self.shared - .active + .publication .write() .unwrap_or_else(PoisonError::into_inner) + .active .take_if(|active| Arc::ptr_eq(active, &generation)) }) .flatten() @@ -398,14 +415,14 @@ fn restore_generation( let generation = Arc::new(rollback.build(id)?); let restored = permit .with_current(|| { - let mut active = shared - .active + let mut publication = shared + .publication .write() .unwrap_or_else(PoisonError::into_inner); - if active.is_some() { + if publication.active.is_some() { return false; } - *active = Some(generation); + publication.active = Some(generation); true }) .unwrap_or(false); diff --git a/crates/gateway-stt/src/generation/snapshot.rs b/crates/gateway-stt/src/generation/snapshot.rs index 4e5d64b1..ee0c4cc2 100644 --- a/crates/gateway-stt/src/generation/snapshot.rs +++ b/crates/gateway-stt/src/generation/snapshot.rs @@ -65,7 +65,7 @@ impl GenerationSpec { Backend::Scripted, factory, policy, - ModelNames::new("scripted-interim".to_owned(), None), + ModelNames::scripted(false), Vec::new(), ); spec.infer_scripted_final = true; @@ -76,10 +76,7 @@ impl GenerationSpec { let engine = SttEngine::new(SharedFactory(Arc::clone(&self.factory)), self.policy) .map_err(SpeechError::Engine)?; let names = if self.infer_scripted_final { - ModelNames::new( - "scripted-interim".to_owned(), - engine.has_final_pass().then(|| "scripted-final".to_owned()), - ) + ModelNames::scripted(engine.has_final_pass()) } else { self.names.clone() }; diff --git a/crates/gateway-stt/src/model.rs b/crates/gateway-stt/src/model.rs index af46f6c9..3a7671d9 100644 --- a/crates/gateway-stt/src/model.rs +++ b/crates/gateway-stt/src/model.rs @@ -1,8 +1,19 @@ -//! Physical speech-model identity inside one active generation. +//! Speech-model identity advertised by one active generation. use gateway_stt_engine::DecodeMode; -/// One active physical speech model advertised by the service. +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, @@ -13,7 +24,7 @@ impl SpeechModelInfo { Self { name } } - /// Returns the configured physical model name. + /// Returns the caller-facing speech model name. #[must_use] pub fn name(&self) -> &str { &self.name @@ -27,10 +38,27 @@ pub(crate) struct ModelNames { } impl ModelNames { - pub(crate) fn new(interim: String, final_model: Option) -> Self { - Self { + 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()), } } @@ -45,11 +73,33 @@ impl ModelNames { } pub(crate) fn infos(&self) -> Vec { - let mut models = Vec::with_capacity(usize::from(self.final_model.is_some()) + 1); + 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/service.rs b/crates/gateway-stt/src/service.rs index 4126b184..f9e43f08 100644 --- a/crates/gateway-stt/src/service.rs +++ b/crates/gateway-stt/src/service.rs @@ -83,7 +83,7 @@ impl SpeechService { self.state.status() } - /// Returns physical models from one point-in-time generation snapshot. + /// Returns physical batch models and any ready logical model from one snapshot. #[must_use] pub fn models(&self) -> Vec { self.state.models() diff --git a/crates/gateway-stt/src/status.rs b/crates/gateway-stt/src/status.rs index 6581b3f7..d18f166d 100644 --- a/crates/gateway-stt/src/status.rs +++ b/crates/gateway-stt/src/status.rs @@ -10,9 +10,9 @@ pub struct SpeechStatus { } impl SpeechStatus { - pub(crate) const fn inactive() -> Self { + pub(crate) const fn unready(configured: bool) -> Self { Self { - configured: false, + configured, ready: false, gpu: false, generation: None, diff --git a/crates/gateway-stt/tests/it/architecture.rs b/crates/gateway-stt/tests/it/architecture.rs index 3777722a..0006a52a 100644 --- a/crates/gateway-stt/tests/it/architecture.rs +++ b/crates/gateway-stt/tests/it/architecture.rs @@ -692,6 +692,52 @@ fn profile_replacement_policy_requires_restartable_rollback_and_fatal_shutdown() } } +#[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"))) diff --git a/crates/gateway-stt/tests/it/generation.rs b/crates/gateway-stt/tests/it/generation.rs index 00416de7..1f33b07a 100644 --- a/crates/gateway-stt/tests/it/generation.rs +++ b/crates/gateway-stt/tests/it/generation.rs @@ -161,7 +161,7 @@ fn replacement_is_serial_and_publishes_one_complete_snapshot() { .iter() .map(gateway_stt::SpeechModelInfo::name) .collect::>(), - ["scripted-interim", "scripted-final"] + ["scripted-interim", "scripted-final", "realtime-transcribe"] ); assert!(first_decoder.worker_dropped()); service.shutdown(); @@ -208,7 +208,21 @@ async fn active_replacement_drains_request_and_job_before_unload_and_publication .await .expect("canceled old request returns") .expect("old request task joins"); - assert!(!service.status().ready(), "closed admission is not ready"); + 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" @@ -243,7 +257,7 @@ async fn active_replacement_drains_request_and_job_before_unload_and_publication .iter() .map(gateway_stt::SpeechModelInfo::name) .collect::>(), - ["scripted-interim", "scripted-final"] + ["scripted-interim", "scripted-final", "realtime-transcribe"] ); service.shutdown(); diff --git a/crates/gateway-stt/tests/it/service.rs b/crates/gateway-stt/tests/it/service.rs index 27f3f4fe..85b635df 100644 --- a/crates/gateway-stt/tests/it/service.rs +++ b/crates/gateway-stt/tests/it/service.rs @@ -2,6 +2,7 @@ use axum::body::Body; use axum::http::{Request, StatusCode}; +use gateway_config::{Config, ProfileName}; use gateway_stt::SpeechService; use gateway_stt::test_fixtures::{ScriptedDecoder, ScriptedModelFactory, scripted_service}; use tower::ServiceExt as _; @@ -27,7 +28,7 @@ fn clones_observe_one_complete_scripted_generation() { .iter() .map(gateway_stt::SpeechModelInfo::name) .collect::>(), - ["scripted-interim", "scripted-final"] + ["scripted-interim", "scripted-final", "realtime-transcribe"] ); service.shutdown(); @@ -35,6 +36,67 @@ fn clones_observe_one_complete_scripted_generation() { 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}"); +} + #[tokio::test] async fn facade_routes_keep_the_temporary_legacy_capability() { let response = SpeechService::new() diff --git a/crates/gateway/src/lib.rs b/crates/gateway/src/lib.rs index f3f59472..fb257b8f 100644 --- a/crates/gateway/src/lib.rs +++ b/crates/gateway/src/lib.rs @@ -89,7 +89,6 @@ mod env_file; mod error; mod handoff; mod hf; -#[cfg(feature = "local")] mod model_info; #[cfg(feature = "local")] mod orphans; @@ -149,8 +148,7 @@ 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")] @@ -895,24 +893,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, })) @@ -971,6 +984,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. @@ -993,6 +1022,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; @@ -1046,18 +1076,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.speech.status().ready(), - 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::>(), @@ -1088,7 +1109,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 @@ -3505,7 +3533,11 @@ mod provisioning_tests { ); 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 @@ -3517,6 +3549,12 @@ mod provisioning_tests { ) .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 @@ -3529,6 +3567,18 @@ mod provisioning_tests { .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(); @@ -3544,8 +3594,28 @@ mod provisioning_tests { .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") 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/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 f1a3cc36..6f3ac61a 100644 --- a/crates/gateway/src/test_support.rs +++ b/crates/gateway/src/test_support.rs @@ -253,4 +253,67 @@ mod tests { }) ); } + + 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/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/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 77845b4d..31c8287f 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -592,7 +592,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 773d970c..15ff72ca 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -130,8 +130,8 @@ N1 | observation | Violates A2 @ crates/gateway-stt/tests/fixtures/realtime: not N2 | observation | Violates A96 @ crates/workshop-server/ui/test/realtime-wire-fixtures.mjs: not determinable from diff | Freeze the realtime transcription wire contract 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 -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 +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 +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 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 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 @@ -149,8 +149,8 @@ N20 | observation | surface-growth @ crates/gateway-stt-engine/src/lib.rs::test_ 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 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 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 -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 +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 +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 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 @@ -161,7 +161,7 @@ N32 | observation | oversized-unit @ crates/gateway-stt/src/realtime/wire/server 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 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 +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 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 From 781172f8f4dfbc305dc6e519d5885d58101449d5 Mon Sep 17 00:00:00 2001 From: Sean Parsons Date: Sun, 6 Sep 2026 16:41:49 -0500 Subject: [PATCH 32/86] Streamline contributor and CI maintenance Improve local validation and CI efficiency without changing the default checkout behavior. Keep generated release metadata out of source control and reduce unnecessary workflow downloads. - `.githooks/pre-commit` runs formatting checks. `.githooks/pre-push` checks the headless gateway and workspace lint, then runs `cargo deny check` only when available. - `README.md` keeps the hooks opt in through `git config core.hooksPath .githooks`. - `.github/workflows/dist-ci/build-setup.yml`, `.github/workflows/nightly.yml`, and `.github/workflows/release-workshop.yml` enable npm caching with `crates/*/ui/package-lock.json` as the dependency input. - `.gitignore` excludes the generated `plan-dist-manifest.json`. - `.github/workflows/release-workshop.yml` limits Linux installation to required packages. Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- .githooks/pre-commit | 6 ++++++ .githooks/pre-push | 16 ++++++++++++++++ .github/workflows/dist-ci/build-setup.yml | 2 ++ .github/workflows/nightly.yml | 6 ++++++ .github/workflows/release-workshop.yml | 4 +++- .gitignore | 1 + README.md | 6 ++++++ 7 files changed, 40 insertions(+), 1 deletion(-) create mode 100755 .githooks/pre-commit create mode 100755 .githooks/pre-push 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/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/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/.gitignore b/.gitignore index 2e450aac..1100be7c 100644 --- a/.gitignore +++ b/.gitignore @@ -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/README.md b/README.md index 15b168ef..83f4b0a7 100644 --- a/README.md +++ b/README.md @@ -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 From 1b50919ded24c409afd3b55b927efb943d501e09 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 16:22:25 -0700 Subject: [PATCH 33/86] Mount Gateway Realtime transcription Expose the additive transcription socket through the speech facade and the existing Gateway authorization wall. Bound session admission, output delivery, replacement shutdown, and typed wire failures while preserving the batch and legacy speech routes. Record the two CI repairs as explicit follow-up work and move later migration targets with the inserted steps. - `SpeechService` owns the shared session registry and route policy, merges the Realtime router with the existing speech surfaces, and exposes focused fixture controls. `with_speech_service` injects prepared speech state without bypassing production route middleware. - `vibe/2026-09-05-2-generic-realtime-stt.md` marks Step 24 complete, inserts event-driven retirement and cross-platform warning repair steps, and renumbers the remaining migration and verification sequence. - `run_socket` emits session, input, item, hypothesis, completion, and failure events. It limits each send to 500 milliseconds and closes replaced generations with code 1012 after reporting affected work. - `session_error` and `item_result` preserve request correlation while separating invalid input, overload, server failure, final-segment overload, and generation replacement. - `realtime_stt.rs` pins authentication order, bearer and cookie access, trusted loopback, hostile origins, duplicate queries, legacy route retention, private audio handling, the eight-session cap, blocked-send release, replacement closure, appendable interim deltas, typed terminal failures, and retry after result capacity drains. Design: new value-object @ crates/gateway-stt/src/realtime/result_mailbox.rs::ItemFailure Design: new shared-mutable-state @ crates/gateway-stt/src/realtime/route.rs::RoutePolicy Design: new surface-growth @ crates/gateway-stt/src/realtime/route.rs boundary: wire Design: new oversized-unit @ crates/gateway-stt/src/realtime/route.rs Design: new dispatch-on-tag @ crates/gateway-stt/src/realtime/route.rs::handle_message deps: &RoutePolicy,&mut Session,&mut WebSocket,Message Design: new dispatch-on-tag @ crates/gateway-stt/src/realtime/route.rs::handle_text deps: &RoutePolicy,&mut Session,&mut WebSocket,&str Design: new pure-function @ crates/gateway-stt/src/realtime/route.rs::event_id deps: &ClientEvent Design: new dispatch-on-tag @ crates/gateway-stt/src/realtime/route.rs::event_id deps: &ClientEvent Design: new pure-function @ crates/gateway-stt/src/realtime/route.rs::session_error deps: &SessionError,Option Design: new dispatch-on-tag @ crates/gateway-stt/src/realtime/route.rs::session_error deps: &SessionError,Option Design: new oversized-unit @ crates/gateway-stt/src/realtime/session/route.rs Design: new oversized-unit @ crates/gateway-stt/src/realtime/wire/server/events.rs Design: new dispatch-on-tag @ crates/gateway-stt/src/realtime/wire/server/events.rs::ServerEvent::item_result Design: new pure-function @ crates/gateway-stt/src/realtime/wire/server/events.rs::item_failure_error deps: &ItemFailure Design: new dispatch-on-tag @ crates/gateway-stt/src/realtime/wire/server/events.rs::item_failure_error deps: &ItemFailure Design: new pure-function @ crates/gateway-stt/src/realtime/wire/server/events.rs::replacement_error deps: OptionalNullable Design: extends facade @ crates/gateway-stt/src/service.rs::SpeechService boundary: pub Design: extends surface-growth @ crates/gateway-stt/src/service.rs::SpeechService::routes boundary: pub Design: new surface-growth @ crates/gateway-stt/src/service.rs::SpeechService::block_realtime_send_after boundary: pub Design: new surface-growth @ crates/gateway-stt/src/service.rs::SpeechService::fail_realtime_precommit boundary: pub Design: new surface-growth @ crates/gateway-stt/src/service.rs::SpeechService::overload_realtime_final_segment boundary: pub Design: new pure-function @ crates/gateway/src/lib.rs::gateway_realtime_origin_allowed deps: &axum::extract::Request Design: new surface-growth @ crates/gateway/src/runner.rs::Gateway::with_speech_service boundary: pub Design: new oversized-unit @ crates/gateway/tests/it/realtime_stt.rs Violates: A2 - credential ownership in crates/gateway-stt/src/realtime is not determinable from diff Pending: N5 - compounds Pending: N6 - compounds Pending: N24 - compounds Pending: N25 - compounds Pending: N30 - compounds Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- Cargo.lock | 2 + crates/gateway-stt/module-ceilings.toml | 21 +- crates/gateway-stt/src/generation/lease.rs | 4 + crates/gateway-stt/src/realtime/item.rs | 14 +- crates/gateway-stt/src/realtime/mod.rs | 6 +- .../src/realtime/result_mailbox.rs | 27 +- crates/gateway-stt/src/realtime/route.rs | 406 +++++++++ crates/gateway-stt/src/realtime/session.rs | 4 + .../gateway-stt/src/realtime/session/items.rs | 6 +- .../gateway-stt/src/realtime/session/route.rs | 142 +++ .../gateway-stt/src/realtime/session/state.rs | 12 + .../gateway-stt/src/realtime/wire/server.rs | 2 + .../src/realtime/wire/server/events.rs | 179 ++++ .../gateway-stt/src/realtime/wire/shared.rs | 40 +- crates/gateway-stt/src/service.rs | 30 + crates/gateway-stt/src/test_fixtures.rs | 4 +- crates/gateway-stt/tests/it/architecture.rs | 4 +- crates/gateway/Cargo.toml | 2 + crates/gateway/README.md | 4 +- crates/gateway/src/lib.rs | 25 + crates/gateway/src/runner.rs | 12 + crates/gateway/tests/it/main.rs | 2 + crates/gateway/tests/it/realtime_stt.rs | 845 ++++++++++++++++++ vibe/2026-09-05-2-generic-realtime-stt.md | 72 +- vibe/archdoc-next.md | 10 +- 25 files changed, 1822 insertions(+), 53 deletions(-) create mode 100644 crates/gateway-stt/src/realtime/route.rs create mode 100644 crates/gateway-stt/src/realtime/session/route.rs create mode 100644 crates/gateway-stt/src/realtime/wire/server/events.rs create mode 100644 crates/gateway/tests/it/realtime_stt.rs diff --git a/Cargo.lock b/Cargo.lock index 7a5b25e6..7bd5baf9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1873,6 +1873,7 @@ name = "gateway" version = "0.2.0" dependencies = [ "axum", + "base64 0.22.1", "block2", "dotenvy", "embed-resource", @@ -1908,6 +1909,7 @@ dependencies = [ "thiserror 2.0.19", "time", "tokio", + "tokio-tungstenite", "tokio-util", "toml 0.8.2", "tower", diff --git a/crates/gateway-stt/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index 31238e0f..1bf8cb14 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -5,7 +5,7 @@ public_root_budget = 6 [migration_targets."stt.rs"] -target_step = "Step 30" +target_step = "Step 32" destination = "removal after the Realtime route and Workshop relay replace the legacy socket" [modules] @@ -15,27 +15,30 @@ destination = "removal after the Realtime route and Workshop relay replace the l "batch/native_tests.rs" = 113 "batch/tests.rs" = 160 "generation.rs" = 467 -"generation/lease.rs" = 126 +"generation/lease.rs" = 130 "generation/snapshot.rs" = 158 "lib.rs" = 41 "model.rs" = 105 "realtime/mod.rs" = 16 "realtime/input.rs" = 195 -"realtime/item.rs" = 157 +"realtime/item.rs" = 163 "realtime/query.rs" = 70 "realtime/registry.rs" = 200 -"realtime/result_mailbox.rs" = 207 -"realtime/session.rs" = 436 +"realtime/result_mailbox.rs" = 232 +"realtime/route.rs" = 406 +"realtime/session.rs" = 440 "realtime/session/items.rs" = 134 -"realtime/session/state.rs" = 82 +"realtime/session/route.rs" = 142 +"realtime/session/state.rs" = 94 "realtime/wire.rs" = 24 "realtime/wire/client.rs" = 363 -"realtime/wire/server.rs" = 388 -"realtime/wire/shared.rs" = 217 +"realtime/wire/server.rs" = 390 +"realtime/wire/server/events.rs" = 179 +"realtime/wire/shared.rs" = 255 "realtime/wire/tests.rs" = 278 "replacement.rs" = 473 "segment.rs" = 239 -"service.rs" = 104 +"service.rs" = 134 "status.rs" = 54 "stt.rs" = 725 "take.rs" = 420 diff --git a/crates/gateway-stt/src/generation/lease.rs b/crates/gateway-stt/src/generation/lease.rs index 0f8b72fc..612a7708 100644 --- a/crates/gateway-stt/src/generation/lease.rs +++ b/crates/gateway-stt/src/generation/lease.rs @@ -69,6 +69,10 @@ impl GenerationLease { 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 { diff --git a/crates/gateway-stt/src/realtime/item.rs b/crates/gateway-stt/src/realtime/item.rs index e55f17e3..f516f5ba 100644 --- a/crates/gateway-stt/src/realtime/item.rs +++ b/crates/gateway-stt/src/realtime/item.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use tokio::task::JoinHandle; use super::input::{InputSnapshot, SealedInput}; -use super::result_mailbox::ItemResult; +use super::result_mailbox::{ItemFailure, ItemResult}; use crate::take::Take; type FinalizationTask = JoinHandle>; @@ -88,6 +88,12 @@ impl CommittedItem { 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 } @@ -105,7 +111,7 @@ impl CommittedItem { .completed(transcript) .ok_or_else(|| "the committed item already reached a terminal outcome".to_owned()), Err(message) => self - .failed(message) + .failed(ItemFailure::TranscriptionFailed(message)) .ok_or_else(|| "the committed item already reached a terminal outcome".to_owned()), } } @@ -125,13 +131,13 @@ impl CommittedItem { }) } - pub(crate) fn failed(&mut self, message: String) -> Option { + 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(), - message, + failure, }) } } diff --git a/crates/gateway-stt/src/realtime/mod.rs b/crates/gateway-stt/src/realtime/mod.rs index 8c43b694..7e4d7ebc 100644 --- a/crates/gateway-stt/src/realtime/mod.rs +++ b/crates/gateway-stt/src/realtime/mod.rs @@ -3,14 +3,14 @@ mod item; mod query; mod registry; mod result_mailbox; +mod route; mod session; mod wire; -#[cfg(feature = "test-fixtures")] pub(crate) use item::CommitReceipt; -#[cfg(feature = "test-fixtures")] 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::{InterimEpoch, Session}; diff --git a/crates/gateway-stt/src/realtime/result_mailbox.rs b/crates/gateway-stt/src/realtime/result_mailbox.rs index 7764f634..093f0143 100644 --- a/crates/gateway-stt/src/realtime/result_mailbox.rs +++ b/crates/gateway-stt/src/realtime/result_mailbox.rs @@ -2,6 +2,31 @@ 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()) + } + } + + 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 { Delta { @@ -20,7 +45,7 @@ pub(crate) enum ItemResult { }, Failed { item_id: String, - message: String, + failure: ItemFailure, }, } diff --git a/crates/gateway-stt/src/realtime/route.rs b/crates/gateway-stt/src/realtime/route.rs new file mode 100644 index 00000000..9e0aaed1 --- /dev/null +++ b/crates/gateway-stt/src/realtime/route.rs @@ -0,0 +1,406 @@ +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); + 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() => { + let Ok(events) = session.finish_ready().await else { + return; + }; + if !send_events(&mut socket, &events, &policy).await { + 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) + .await + .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).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, + } +} + +async 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())?; + } + session + .decode_interim() + .await + .map(|event| event.into_iter().collect()) +} + +fn commit_events(session: &mut Session) -> Result, SessionError> { + let receipt = session.commit()?; + let item_id = receipt.item_id().to_owned(); + let mut events = Vec::from(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 | SessionError::Mailbox(MailboxError::ResultAtCapacity) => { + ClientError::overload( + "result_queue_overload", + "The session result queue is full", + None, + 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 index 031a35c3..4e887d61 100644 --- a/crates/gateway-stt/src/realtime/session.rs +++ b/crates/gateway-stt/src/realtime/session.rs @@ -7,6 +7,7 @@ use super::wire::{ClientError, EffectiveSession, IdGenerator, ServerEvent}; use crate::generation::GenerationLease; mod items; +mod route; mod state; #[cfg(test)] @@ -65,6 +66,9 @@ impl Session { self.canceled_tasks.push(task); } self.input = None; + self.pending_interim.clear(); + self.standard_interim_committed.clear(); + self.hypothesis_revision = 0; Ok(()) } diff --git a/crates/gateway-stt/src/realtime/session/items.rs b/crates/gateway-stt/src/realtime/session/items.rs index aefa3052..738ede3f 100644 --- a/crates/gateway-stt/src/realtime/session/items.rs +++ b/crates/gateway-stt/src/realtime/session/items.rs @@ -2,7 +2,7 @@ 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::{ItemResult, MailboxError}; +use crate::realtime::result_mailbox::{ItemFailure, ItemResult, MailboxError}; impl Session { pub(crate) fn commit(&mut self) -> Result { @@ -32,7 +32,7 @@ impl Session { let receipt = item.receipt(); self.previous_item_id = Some(item_id.clone()); if let Some(failure) = pending_failure - && let Some(terminal) = item.failed(failure) + && let Some(terminal) = item.failed(ItemFailure::from_precommit(&failure)) { self.results.set_terminal(&item_id, terminal)?; } @@ -104,7 +104,7 @@ impl Session { .get_mut(item_id) .ok_or(MailboxError::UnknownItem)?; let terminal = item - .failed(message) + .failed(ItemFailure::TranscriptionFailed(message)) .ok_or(MailboxError::TerminalAlreadySet)?; self.results.set_terminal(item_id, terminal)?; Ok(()) 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..17cf8360 --- /dev/null +++ b/crates/gateway-stt/src/realtime/session/route.rs @@ -0,0 +1,142 @@ +use std::time::Duration; + +use gateway_stt_engine::{DecodeMode, DecodeRequest}; + +use super::{Session, SessionError}; +use crate::realtime::result_mailbox::{ItemResult, SESSION_RESULT_CAPACITY}; +use crate::realtime::wire::ServerEvent; + +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) async fn decode_interim(&mut self) -> Result, SessionError> { + let input = self.input.as_ref().ok_or(SessionError::NoInput)?; + let include_hypothesis = input.snapshot().include_hypothesis(); + let engine = self + .engine + .as_ref() + .ok_or(SessionError::GenerationUnavailable)?; + let transcript = engine + .decode(DecodeRequest::new( + DecodeMode::Interim, + input.take().uncommitted_snapshot(engine.window_samples()), + input.take().guidance().to_vec(), + input.take().finalized(), + )) + .await + .map_err(|_| SessionError::Inference)?; + if transcript.is_empty() { + return Ok(None); + } + let finalized = input.take().finalized(); + let update = input.take().next_interim(&transcript); + if !include_hypothesis { + if let Some((committed, _)) = update { + let delta = committed + .strip_prefix(&self.standard_interim_committed) + .ok_or(SessionError::Inference)?; + if !delta.is_empty() { + self.pending_interim.push(delta.to_owned()); + } + self.standard_interim_committed = committed; + } + return Ok(None); + } + self.hypothesis_revision = self + .hypothesis_revision + .checked_add(1) + .ok_or(SessionError::EpochExhausted)?; + let (agreed, tentative) = update.unwrap_or_else(|| (String::new(), transcript)); + Ok(Some(ServerEvent::hypothesis( + self.ids.event(), + input.item_id().to_owned(), + self.hypothesis_revision, + finalized, + agreed, + tentative, + u64::try_from(Duration::from_secs_f64(input.buffered_duration_seconds()).as_millis()) + .unwrap_or(u64::MAX), + ))) + } + + 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 + } +} diff --git a/crates/gateway-stt/src/realtime/session/state.rs b/crates/gateway-stt/src/realtime/session/state.rs index b439adef..1d7cda06 100644 --- a/crates/gateway-stt/src/realtime/session/state.rs +++ b/crates/gateway-stt/src/realtime/session/state.rs @@ -31,6 +31,12 @@ pub(crate) enum SessionError { 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}")] @@ -53,6 +59,9 @@ pub(crate) struct Session { 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, } @@ -76,6 +85,9 @@ impl Session { 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/server.rs b/crates/gateway-stt/src/realtime/wire/server.rs index f7304a33..46704f7c 100644 --- a/crates/gateway-stt/src/realtime/wire/server.rs +++ b/crates/gateway-stt/src/realtime/wire/server.rs @@ -7,6 +7,8 @@ use super::shared::{ 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 { 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..40a4edfb --- /dev/null +++ b/crates/gateway-stt/src/realtime/wire/server/events.rs @@ -0,0 +1,179 @@ +use super::{ + ConversationItem, DurationUsage, EffectiveSession, InputAudioContent, ServerEvent, WireError, +}; +use crate::realtime::result_mailbox::{ItemFailure, ItemResult}; +use crate::realtime::wire::shared::{OptionalNullable, RequiredNullable}; + +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, + finalized: String, + agreed: String, + tentative: String, + audio_end_ms: u64, + ) -> Self { + Self::TranscriptionHypothesis { + event_id, + item_id, + content_index: 0, + revision, + transcript: format!("{finalized}{agreed}{tentative}"), + finalized, + agreed, + tentative, + audio_start_ms: 0, + 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 { + ItemResult::Delta { + item_id, + transcript, + } => Self::transcription_delta(event_id, item_id, transcript), + 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 index 7726dcec..9ff3dc4a 100644 --- a/crates/gateway-stt/src/realtime/wire/shared.rs +++ b/crates/gateway-stt/src/realtime/wire/shared.rs @@ -43,6 +43,7 @@ pub(super) enum Correlation { #[derive(Debug, Clone, Eq, PartialEq)] pub(crate) struct ClientError { + kind: &'static str, code: &'static str, message: String, param: Option, @@ -57,6 +58,7 @@ impl ClientError { correlation: Correlation, ) -> Self { Self { + kind: "invalid_request_error", code, message: message.into(), param: param.map(str::to_owned), @@ -66,7 +68,7 @@ impl ClientError { 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("invalid_request_error")); + 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 { @@ -83,6 +85,42 @@ impl ClientError { } 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)] diff --git a/crates/gateway-stt/src/service.rs b/crates/gateway-stt/src/service.rs index f9e43f08..b642dd20 100644 --- a/crates/gateway-stt/src/service.rs +++ b/crates/gateway-stt/src/service.rs @@ -6,12 +6,17 @@ 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 { @@ -89,11 +94,36 @@ impl SpeechService { 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 temporary legacy Gateway routes. #[cfg(not(miri))] pub fn routes(&self) -> axum::Router { crate::batch::routes(self.state.clone()) .merge(crate::stt::gateway_router(self.state.clone())) + .merge(crate::realtime::routes( + self.state.clone(), + self.sessions.clone(), + self.realtime_policy.clone(), + )) } /// Returns the temporary Workshop-hosted legacy routes. diff --git a/crates/gateway-stt/src/test_fixtures.rs b/crates/gateway-stt/src/test_fixtures.rs index 70564943..fcfdae47 100644 --- a/crates/gateway-stt/src/test_fixtures.rs +++ b/crates/gateway-stt/src/test_fixtures.rs @@ -411,10 +411,10 @@ fn result_value(result: ItemResult) -> serde_json::Value { "transcript": transcript, "seconds": seconds, }), - ItemResult::Failed { item_id, message } => serde_json::json!({ + ItemResult::Failed { item_id, failure } => serde_json::json!({ "type": "failed", "item_id": item_id, - "message": message, + "message": failure.diagnostic(), }), } } diff --git a/crates/gateway-stt/tests/it/architecture.rs b/crates/gateway-stt/tests/it/architecture.rs index 0006a52a..6e6a3f69 100644 --- a/crates/gateway-stt/tests/it/architecture.rs +++ b/crates/gateway-stt/tests/it/architecture.rs @@ -82,7 +82,7 @@ const DEPENDENCY_POLICIES: [DependencyPolicy; 7] = [ ], temporary_edges: &[TemporaryEdge { dependency: "workshop-server", - removal_step: "Step 30", + removal_step: "Step 32", }], }, DependencyPolicy { @@ -130,7 +130,7 @@ const MIGRATION_POLICIES: [MigrationPolicy; 4] = [ crate_name: "gateway-stt", targets: &[MigrationPolicyTarget { module: "stt.rs", - target_step: "Step 30", + target_step: "Step 32", destination: "removal after the Realtime route and Workshop relay replace the legacy socket", }], }, diff --git a/crates/gateway/Cargo.toml b/crates/gateway/Cargo.toml index f26bda02..dfc9aa3a 100644 --- a/crates/gateway/Cargo.toml +++ b/crates/gateway/Cargo.toml @@ -134,10 +134,12 @@ config-ui = ["dep:gateway-config-ui"] stt = ["dep:gateway-stt"] [dev-dependencies] +base64.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"] } tempfile.workspace = true diff --git a/crates/gateway/README.md b/crates/gateway/README.md index 33adfdce..97a078b3 100644 --- a/crates/gateway/README.md +++ b/crates/gateway/README.md @@ -22,7 +22,7 @@ The config path comes from the `--config` flag or the `PROMPTFORGE_GATEWAY_CONFI 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 streaming dictation at `/stt`, capability discovery at `GET /stt/capability`, and OpenAI-compatible multipart transcription at `POST /v1/audio/transcriptions`. +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`, streaming dictation at `/stt`, capability discovery at `GET /stt/capability`, and 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()`). @@ -92,7 +92,7 @@ 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` crate: the transcription engine lifecycle, `WS /v1/realtime?intent=transcription`, 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. - `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 because its `bind` and `open_browser` settings are inert. diff --git a/crates/gateway/src/lib.rs b/crates/gateway/src/lib.rs index fb257b8f..aea13085 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 and +//! temporary legacy speech routes; 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 @@ -133,6 +136,8 @@ use axum::Json; use axum::body::Body; 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")] @@ -610,6 +615,9 @@ async fn authorize_stt_route( next: axum::middleware::Next, ) -> Result { check_auth(&state, &caller).await?; + 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! { response = next.run(request) => Ok(response), @@ -617,6 +625,23 @@ async fn authorize_stt_route( } } +#[cfg(feature = "stt")] +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"; diff --git a/crates/gateway/src/runner.rs b/crates/gateway/src/runner.rs index 8cc9a371..caee09aa 100644 --- a/crates/gateway/src/runner.rs +++ b/crates/gateway/src/runner.rs @@ -369,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. /// diff --git a/crates/gateway/tests/it/main.rs b/crates/gateway/tests/it/main.rs index 626bb568..2a5c8c2e 100644 --- a/crates/gateway/tests/it/main.rs +++ b/crates/gateway/tests/it/main.rs @@ -34,6 +34,8 @@ mod local; mod profiles; mod progress; mod queue; +#[cfg(feature = "stt")] +mod realtime_stt; mod rerank; mod sidecar; mod surface; diff --git a/crates/gateway/tests/it/realtime_stt.rs b/crates/gateway/tests/it/realtime_stt.rs new file mode 100644 index 00000000..d54a75da --- /dev/null +++ b/crates/gateway/tests/it/realtime_stt.rs @@ -0,0 +1,845 @@ +//! Mounted Realtime transcription route through the production Gateway wall. + +use std::net::SocketAddr; +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 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 { + 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, 15, 500).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 { + let message = tokio::time::timeout(PHASE_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"); +} + +fn audio() -> String { + let bytes = vec![0_u8; 24_000 * 2 / 10]; + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +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 +} + +#[tokio::test] +async fn gateway_auth_origin_query_and_legacy_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); + + for (method, path) in [ + ("POST", "/v1/audio/transcriptions"), + ("GET", "/stt"), + ("GET", "/stt/capability"), + ] { + let response = send_within( + http.request( + reqwest::Method::from_bytes(method.as_bytes()).expect("method is valid"), + format!("http://{}{path}", strict.addr), + ) + .bearer_auth("test-token"), + ) + .await; + assert_ne!( + response.status(), + reqwest::StatusCode::NOT_FOUND, + "{method} {path} remains mounted" + ); + } + 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; +} + +#[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" + ); + + send( + &mut socket, + serde_json::json!({ + "type": "input_audio_buffer.append", + "event_id": "append-one", + "audio": audio() + }), + ) + .await; + send( + &mut socket, + serde_json::json!({ + "type": "input_audio_buffer.append", + "event_id": "append-two", + "audio": audio() + }), + ) + .await; + 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 admission_is_bounded_and_replacement_closes_with_1012() { + let interim = ScriptedDecoder::new(); + let final_decoder = ScriptedDecoder::new(); + final_decoder.park_next(); + 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); + } + assert_eq!( + rejected( + server.addr, + "intent=transcription", + Some("test-token"), + None + ) + .await, + 429 + ); + for mut socket in sockets.drain(1..) { + socket.close(None).await.expect("socket closes"); + } + send( + &mut sockets[0], + serde_json::json!({ + "type": "input_audio_buffer.append", + "audio": audio() + }), + ) + .await; + 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; + let parked = final_decoder.clone(); + assert!( + tokio::task::spawn_blocking(move || parked.wait_until_parked(PHASE_TIMEOUT)) + .await + .expect("park observer joins"), + "committed item owns its final decode" + ); + + let replacement = ScriptedDecoder::new(); + let replacement_final = ScriptedDecoder::new(); + let replacement_service = service.clone(); + 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); + drop(sockets); + final_decoder.release(); + + 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; +} + +#[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; + + send( + &mut socket, + serde_json::json!({ + "type": "input_audio_buffer.append", + "event_id": "inference", + "audio": audio() + }), + ) + .await; + expect_error( + &mut socket, + "server_error", + "internal_error", + "Transcription failed", + serde_json::Value::Null, + "inference", + ) + .await; + + 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 _ in 0..3 { + 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 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; +} + +#[tokio::test] +async fn mounted_terminal_failures_preserve_their_typed_wire_reason() { + 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}"); + + socket.close(None).await.expect("socket closes"); + drop(socket); + server.shutdown().await; + } +} + +#[tokio::test] +async fn standard_result_capacity_rejects_before_audio_mutation_and_retries() { + let interim = ScriptedDecoder::new(); + interim.push_text("word0 alternative"); + for end in 1..=16 { + interim.push_text( + (0..=end) + .map(|index| format!("word{index}")) + .collect::>() + .join(" "), + ); + } + interim.push_text("retry"); + let final_decoder = ScriptedDecoder::new(); + final_decoder.push_text("authoritative"); + 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 _ in 0..17 { + send( + &mut socket, + serde_json::json!({ + "type": "input_audio_buffer.append", + "audio": audio() + }), + ) + .await; + } + send( + &mut socket, + serde_json::json!({ + "type": "input_audio_buffer.append", + "event_id": "capacity-plus-one", + "audio": audio() + }), + ) + .await; + expect_error( + &mut socket, + "overload_error", + "result_queue_overload", + "The session result queue is full", + serde_json::Value::Null, + "capacity-plus-one", + ) + .await; + assert_eq!( + interim.requests().len(), + 17, + "rejected append starts no decode" + ); + + 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; + for _ in 0..16 { + expect_type( + &mut socket, + "conversation.item.input_audio_transcription.delta", + ) + .await; + } + expect_type( + &mut socket, + "conversation.item.input_audio_transcription.completed", + ) + .await; + let final_requests = final_decoder.requests(); + assert_eq!(final_requests.len(), 1); + assert_eq!( + final_requests[0].samples().len(), + 17 * 1_600, + "capacity-plus-one audio was not incorporated" + ); + + send( + &mut socket, + serde_json::json!({ + "type": "input_audio_buffer.append", + "audio": audio() + }), + ) + .await; + let retried = interim.clone(); + assert!( + tokio::task::spawn_blocking(move || retried.wait_for_requests(18, PHASE_TIMEOUT)) + .await + .expect("request observer joins"), + "append retries after committed deltas drain" + ); + + socket.close(None).await.expect("socket closes"); + drop(socket); + server.shutdown().await; +} diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 31c8287f..8b82ffbe 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -35,6 +35,12 @@ todos: - 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: pending + - id: ci-gateway-platform-warnings + content: Restore warnings-denied Gateway builds on non-Windows hosts + status: pending isProject: false --- @@ -59,7 +65,7 @@ isProject: false - 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 32 are the sole logging exception. + - 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 34 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. @@ -238,6 +244,8 @@ isProject: false - 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. - 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. @@ -350,7 +358,7 @@ isProject: false 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 25 through 29, adds only `workshop-server -> shared-loopback`. Final Phase C, Step 30 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. +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 31, adds only `workshop-server -> shared-loopback`. Final Phase C, Step 32 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] @@ -604,7 +612,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. @@ -614,7 +622,29 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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: Add the Workshop relay beside legacy +### Step 25: Make session retirement verification event-driven + +- 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 + +- 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 - 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. @@ -623,25 +653,25 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 26: Prove the actual worklet bytes +### Step 28: Prove the actual worklet bytes - 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 25 additive relay; byte parity gates browser migration. +- Consumes and gates: consumes Step 11 language-neutral bytes and Step 27 additive relay; byte parity gates browser migration. -### Step 27: Migrate Workshop browser speech +### Step 29: Migrate Workshop browser speech - 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, 25, and 26; browser acceptance gates independent full-path automation. +- Consumes and gates: consumes Steps 3, 27, and 28; browser acceptance gates independent full-path automation. -### Step 28: Prove both fixture-driven halves +### Step 30: Prove both fixture-driven halves - 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. @@ -649,9 +679,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 27; both independent halves must pass before packaging. +- Consumes and gates: consumes Steps 24 through 29; both independent halves must pass before packaging. -### Step 29: Pass installed Windows microphone acceptance +### Step 31: Pass installed Windows microphone acceptance - 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` steps `Build and stage the gateway sidecar`, `Build the app`, and `Install and check (Windows)`, then record installed-package microphone revision, completion, second take, clear, cancellation, and recoverable permission or device failure with binary hashes and timestamps. @@ -662,12 +692,12 @@ The architecture harness enforces exact workspace-package edges across normal, d - `C:\Users\Vinnie\cursor\promptforge\crates\workshop`: `if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY)) { throw 'TAURI_SIGNING_PRIVATE_KEY is required by the release workflow' }; if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD)) { throw 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD is required by the release workflow' }; $env:RUSTUP_TOOLCHAIN='stable'; cargo tauri build --bundles nsis` - `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 28; the operator must exercise the installed application and record passing evidence, which alone gates legacy removal. +- Consumes and gates: consumes Step 30; the operator must exercise the installed application and record passing evidence, which alone gates legacy removal. -### Step 30: Remove legacy seams and tests +### Step 32: Remove legacy seams and tests - 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, 25, 27, and 28 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. +- 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` @@ -678,7 +708,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 31: Finalize architecture and documentation +### Step 33: Finalize architecture and documentation - 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. @@ -688,9 +718,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 30 final topology; final verification starts only with zero temporary exceptions. +- Consumes and gates: consumes Step 32 final topology; final verification starts only with zero temporary exceptions. -### Step 32: Bookend Gateway serving logs +### Step 34: Bookend Gateway serving logs - 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. @@ -698,9 +728,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 31 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 33's full release verification must pass after this change. +- Consumes and gates: this operator-requested observability correction is independent of STT and follows Step 33 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 35's full release verification must pass after this change. -### Step 33: Run every release gate and repeat acceptance +### Step 35: Run every release gate and repeat acceptance - Artifacts: append command results, hashes, ratchet counts, native equivalence, generated-doc cleanliness, and repeated installed-microphone evidence to `design/generic-realtime-stt-acceptance.md`; change no implementation. - Scope: run every exit criterion independently under PowerShell 5.1 and stop on any failure. @@ -734,6 +764,6 @@ The architecture harness enforces exact workspace-package edges across normal, d - `C:\Users\Vinnie\cursor\promptforge\crates\workshop`: `if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY)) { throw 'TAURI_SIGNING_PRIVATE_KEY is required by the release workflow' }; if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD)) { throw 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD is required by the release workflow' }; $env:RUSTUP_TOOLCHAIN='stable'; cargo tauri build --bundles nsis` - `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 32, then repeats the Step 29 installed-package microphone scenarios. Completion requires every command, ratchet, generated-doc check, and physical scenario to pass with no open finding. +- Consumes and gates: consumes Step 34, then repeats the Step 31 installed-package microphone scenarios. 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 32's exact two records, or completed Workshop baseline-ratchet work. VAD tuning, alignment, 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 +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 34's exact two records, or completed Workshop baseline-ratchet work. VAD tuning, alignment, 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/archdoc-next.md b/vibe/archdoc-next.md index 15ff72ca..29f4b26b 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -130,8 +130,8 @@ N1 | observation | Violates A2 @ crates/gateway-stt/tests/fixtures/realtime: not N2 | observation | Violates A96 @ crates/workshop-server/ui/test/realtime-wire-fixtures.mjs: not determinable from diff | Freeze the realtime transcription wire contract 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 -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 +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 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 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 @@ -149,13 +149,13 @@ N20 | observation | surface-growth @ crates/gateway-stt-engine/src/lib.rs::test_ 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 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 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 -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 +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 +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 +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 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 From dee47d766397bfe715e50dc1c8ca08759a6ff3ad Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 16:38:48 -0700 Subject: [PATCH 34/86] Make session retirement cleanup event-driven Move retired task joins into registry-owned asynchronous cleanup so session admission remains occupied until all canceled work has terminated. Signal cleanup only after admission is released, preserve non-cancellation join failures, and expose deterministic hooks for wall-clock-bounded verification. - `SessionRegistry` replaces caller-driven retirement polling with a shared cleanup signal and one spawned join owner. `RegistryState` retains only pure counters, while `RegistryShared` owns synchronization and notification. - `SessionRegistration::retire` aborts interim and finalization tasks, joins both sets, records panic failures, releases admission, then emits one cleanup event. - `RealtimeSessionRegistryFixture` exposes cleanup event count, notification, and failed-join count. `RealtimeSessionFixture::replace_finalization` injects controlled finalization work for tests. - `dropping_session_retains_admission_until_interim_cleanup_joins`, `dropping_session_retains_admission_until_finalization_cleanup_joins`, and `retired_task_join_failures_are_preserved` pin capacity retention, slot reuse, both task classes, and panic visibility. - `realtime_retirement_is_registry_owned_event_driven_and_keeps_state_pure` rejects scheduler polling and runtime handles in pure registry accounting. `module-ceilings.toml` raises four exact ceilings for the added retirement and fixture code. Design: extends shared-mutable-state @ crates/gateway-stt/src/realtime/registry.rs::SessionRegistry Design: extends oversized-unit @ crates/gateway-stt/src/realtime/item.rs Design: extends oversized-unit @ crates/gateway-stt/src/realtime/registry.rs Design: extends oversized-unit @ crates/gateway-stt/src/realtime/session/items.rs Design: extends surface-growth @ crates/gateway-stt/src/test_fixtures.rs::RealtimeSessionRegistryFixture boundary: pub Design: extends surface-growth @ crates/gateway-stt/src/test_fixtures.rs::RealtimeSessionFixture boundary: pub Design: extends oversized-unit @ crates/gateway-stt/src/test_fixtures.rs Design: new oversized-unit @ crates/gateway-stt/tests/it/architecture.rs::realtime_retirement_is_registry_owned_event_driven_and_keeps_state_pure Design: extends clone-block @ crates/gateway-stt/tests/it/realtime_session.rs Design: extends oversized-unit @ crates/gateway-stt/tests/it/realtime_session.rs Violates: A2 - credential ownership in crates/gateway-stt/src/realtime is not determinable from diff Pending: N6 - compounds Pending: N24 - compounds Pending: N30 - compounds Pending: N34 - compounds Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- crates/gateway-stt/module-ceilings.toml | 8 +- crates/gateway-stt/src/realtime/item.rs | 7 + crates/gateway-stt/src/realtime/registry.rs | 190 +++++++++++------- .../gateway-stt/src/realtime/session/items.rs | 20 ++ crates/gateway-stt/src/test_fixtures.rs | 30 ++- crates/gateway-stt/tests/it/architecture.rs | 82 ++++++++ .../gateway-stt/tests/it/realtime_session.rs | 184 ++++++++++++++++- vibe/2026-09-05-2-generic-realtime-stt.md | 4 +- vibe/archdoc-next.md | 8 +- 9 files changed, 432 insertions(+), 101 deletions(-) diff --git a/crates/gateway-stt/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index 1bf8cb14..6076481a 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -21,13 +21,13 @@ destination = "removal after the Realtime route and Workshop relay replace the l "model.rs" = 105 "realtime/mod.rs" = 16 "realtime/input.rs" = 195 -"realtime/item.rs" = 163 +"realtime/item.rs" = 170 "realtime/query.rs" = 70 -"realtime/registry.rs" = 200 +"realtime/registry.rs" = 234 "realtime/result_mailbox.rs" = 232 "realtime/route.rs" = 406 "realtime/session.rs" = 440 -"realtime/session/items.rs" = 134 +"realtime/session/items.rs" = 154 "realtime/session/route.rs" = 142 "realtime/session/state.rs" = 94 "realtime/wire.rs" = 24 @@ -46,7 +46,7 @@ destination = "removal after the Realtime route and Workshop relay replace the l "take/finalization.rs" = 177 "take/state.rs" = 82 "take/text.rs" = 9 -"test_fixtures.rs" = 420 +"test_fixtures.rs" = 444 "test_fixtures/generation.rs" = 100 "test_fixtures/native.rs" = 42 "test_fixtures/segment.rs" = 12 diff --git a/crates/gateway-stt/src/realtime/item.rs b/crates/gateway-stt/src/realtime/item.rs index f516f5ba..5ef331ba 100644 --- a/crates/gateway-stt/src/realtime/item.rs +++ b/crates/gateway-stt/src/realtime/item.rs @@ -120,6 +120,13 @@ impl CommittedItem { 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; diff --git a/crates/gateway-stt/src/realtime/registry.rs b/crates/gateway-stt/src/realtime/registry.rs index 706a0c00..cdc957b3 100644 --- a/crates/gateway-stt/src/realtime/registry.rs +++ b/crates/gateway-stt/src/realtime/registry.rs @@ -1,9 +1,8 @@ -use std::fmt; use std::future::Future; -use std::pin::Pin; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; -use std::task::{Context, Poll}; +use tokio::sync::Notify; use tokio::task::JoinHandle; pub(crate) const MAX_ACTIVE_REALTIME_SESSIONS: usize = 8; @@ -17,105 +16,159 @@ pub(crate) enum RegisterError { #[derive(Default)] struct RegistryState { active: usize, - retiring: Vec, + retired_task_failures: usize, } -impl RegistryState { - fn reap_retired(&mut self) { - let before = self.retiring.len(); - self.retiring.retain_mut(|session| !session.joined()); - self.active = self - .active - .saturating_sub(before.saturating_sub(self.retiring.len())); - } +#[derive(Debug, Default)] +struct CleanupSignal { + generation: AtomicUsize, + notified: Notify, } -impl fmt::Debug for RegistryState { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("RegistryState") - .field("active", &self.active) - .field("retiring", &self.retiring.len()) - .finish() +impl CleanupSignal { + fn emit(&self) { + self.generation.fetch_add(1, Ordering::Release); + self.notified.notify_waiters(); } -} -trait RetiredTask: Send { - fn poll_join(&mut self) -> Poll<()>; -} + fn event_count(&self) -> usize { + self.generation.load(Ordering::Acquire) + } -impl RetiredTask for JoinHandle -where - T: Send + 'static, -{ - fn poll_join(&mut self) -> Poll<()> { - let waker = futures_util::task::noop_waker_ref(); - let mut context = Context::from_waker(waker); - Pin::new(self).poll(&mut context).map(|_result| ()) + 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; + } + } } } -struct RetiringSession { - tasks: Vec>, +#[derive(Debug, Default)] +struct RegistryShared { + state: Mutex, + cleanup: CleanupSignal, } -impl RetiringSession { - fn joined(&mut self) -> bool { - let mut index = 0; - while index < self.tasks.len() { - if self.tasks[index].poll_join().is_ready() { - drop(self.tasks.remove(index)); - } else { - index += 1; +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); } - self.tasks.is_empty() + } + 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 { - state: Arc>, + shared: Arc, } impl SessionRegistry { pub(crate) fn register(&self) -> Result { - let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); - state.reap_retired(); + 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 { - state: Some(Arc::clone(&self.state)), + shared: Some(Arc::clone(&self.shared)), }) } pub(crate) fn active(&self) -> usize { - let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); - state.reap_retired(); - state.active + 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 owned_without_reaping(&self) -> usize { - self.state + pub(crate) fn retired_task_failures(&self) -> usize { + self.shared + .state .lock() .unwrap_or_else(PoisonError::into_inner) - .active + .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 { - state: Option>>, + shared: Option>, } impl SessionRegistration { pub(crate) fn retire( &mut self, - mut interim_tasks: Vec>, - mut finalization_tasks: Vec>, + interim_tasks: Vec>, + finalization_tasks: Vec>, ) where T: Send + 'static, U: Send + 'static, @@ -129,38 +182,19 @@ impl SessionRegistration { if interim_tasks.is_empty() && finalization_tasks.is_empty() { return; } - let Some(state) = self.state.take() else { + let Some(shared) = self.shared.take() else { return; }; - state - .lock() - .unwrap_or_else(PoisonError::into_inner) - .retiring - .push(RetiringSession { - tasks: interim_tasks - .drain(..) - .map(|task| Box::new(task) as Box) - .chain( - finalization_tasks - .drain(..) - .map(|task| Box::new(task) as Box), - ) - .collect(), - }); - // The registry keeps this admission occupied until reap_retired - // polls every canceled task join to completion. - drop(state); + shared.retire(interim_tasks, finalization_tasks); } } impl Drop for SessionRegistration { fn drop(&mut self) { - let Some(state) = self.state.take() else { + let Some(shared) = self.shared.take() else { return; }; - let mut state = state.lock().unwrap_or_else(PoisonError::into_inner); - debug_assert!(state.active > 0); - state.active = state.active.saturating_sub(1); + shared.release_admission(); } } diff --git a/crates/gateway-stt/src/realtime/session/items.rs b/crates/gateway-stt/src/realtime/session/items.rs index 738ede3f..d85d176a 100644 --- a/crates/gateway-stt/src/realtime/session/items.rs +++ b/crates/gateway-stt/src/realtime/session/items.rs @@ -1,3 +1,6 @@ +#[cfg(feature = "test-fixtures")] +use std::future::Future; + use super::state::{ MAX_COMMITTED_ITEMS_PER_SESSION, SESSION_CANCEL_JOIN_CAPACITY, Session, SessionError, }; @@ -52,6 +55,23 @@ impl Session { .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(()) + } + pub(crate) fn committed_prompt_and_guidance(&self, item_id: &str) -> Option<(&str, &[String])> { self.committed .get(item_id) diff --git a/crates/gateway-stt/src/test_fixtures.rs b/crates/gateway-stt/src/test_fixtures.rs index fcfdae47..f403586a 100644 --- a/crates/gateway-stt/src/test_fixtures.rs +++ b/crates/gateway-stt/src/test_fixtures.rs @@ -72,10 +72,21 @@ impl RealtimeSessionRegistryFixture { self.inner.active() } - /// Returns owned admission without polling retiring task destructors. + /// Returns the number of registry cleanup events emitted. #[must_use] - pub fn owned_without_reaping(&self) -> usize { - self.inner.owned_without_reaping() + 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() } } @@ -276,6 +287,19 @@ impl RealtimeSessionFixture { 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 diff --git a/crates/gateway-stt/tests/it/architecture.rs b/crates/gateway-stt/tests/it/architecture.rs index 6e6a3f69..3b399169 100644 --- a/crates/gateway-stt/tests/it/architecture.rs +++ b/crates/gateway-stt/tests/it/architecture.rs @@ -656,6 +656,88 @@ fn generation_quiescence_uses_explicit_ownership_without_item_transfer() { ); } +#[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}" + ); + } +} + #[test] fn profile_replacement_policy_requires_restartable_rollback_and_fatal_shutdown() { let generation = read(&crate_root("gateway-stt").join("src/generation.rs")); diff --git a/crates/gateway-stt/tests/it/realtime_session.rs b/crates/gateway-stt/tests/it/realtime_session.rs index 39b2690c..3a4eb086 100644 --- a/crates/gateway-stt/tests/it/realtime_session.rs +++ b/crates/gateway-stt/tests/it/realtime_session.rs @@ -81,6 +81,16 @@ impl Future for BlockingPoll { } } +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 @@ -94,13 +104,19 @@ fn wait_until_started(started: &Arc<(Mutex, Condvar)>) { } async fn wait_until(predicate: impl Fn() -> bool) { - for _ in 0..1_000 { - if predicate() { - return; - } - tokio::task::yield_now().await; - } - panic!("condition did not become true within the bounded yield budget"); + 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] @@ -189,6 +205,9 @@ async fn dropping_session_retains_admission_until_interim_cleanup_joins() { .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"); @@ -202,14 +221,159 @@ async fn dropping_session_retains_admission_until_interim_cleanup_joins() { .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.owned_without_reaping(), - 1, + 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); - wait_until(|| registry.active() == 0).await; + 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] diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 8b82ffbe..43f1783b 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -37,7 +37,7 @@ todos: status: completed - id: ci-session-retirement content: Make session retirement verification event-driven instead of scheduler-yield-counted - status: pending + status: completed - id: ci-gateway-platform-warnings content: Restore warnings-denied Gateway builds on non-Windows hosts status: pending @@ -622,7 +622,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 29f4b26b..a8bd5ef7 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -131,7 +131,7 @@ N2 | observation | Violates A96 @ crates/workshop-server/ui/test/realtime-wire-f 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 +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 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 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 @@ -149,17 +149,17 @@ N20 | observation | surface-growth @ crates/gateway-stt-engine/src/lib.rs::test_ 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 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 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 +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 +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 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 +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 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 From 16c1b29af1f2713ff3e938534e131ad273ef82d3 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 17:02:49 -0700 Subject: [PATCH 35/86] Gate Gateway Windows-only declarations Keep warnings-denied Gateway builds clean on non-Windows hosts. Compile the application manifest and unsafe-code expectation only where their Windows consumers exist. - `MANIFEST` is compiled only on Windows, where resource embedding consumes it. - `main` applies its unsafe-code expectation only on Windows while the DPI call remains target-gated. - `the_manifest_constant_is_compiled_only_for_windows` and `the_dpi_unsafe_expectation_exists_only_for_windows` pin both conditional declarations in source checks. Violates: A2 - not determinable from diff Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- crates/gateway/build.rs | 1 + crates/gateway/src/main.rs | 36 +++++++++++++++++++++-- vibe/2026-09-05-2-generic-realtime-stt.md | 4 +-- 3 files changed, 36 insertions(+), 5 deletions(-) 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/src/main.rs b/crates/gateway/src/main.rs index d85ffb89..3e4231f9 100644 --- a/crates/gateway/src/main.rs +++ b/crates/gateway/src/main.rs @@ -43,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 @@ -391,6 +394,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); diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 43f1783b..344269a1 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -40,7 +40,7 @@ todos: status: completed - id: ci-gateway-platform-warnings content: Restore warnings-denied Gateway builds on non-Windows hosts - status: pending + status: completed isProject: false --- @@ -634,7 +634,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. From 06cba48a997c76a08d52bcd010dcce9552be9605 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 17:21:17 -0700 Subject: [PATCH 36/86] Add the Workshop Realtime relay Expose a same-origin Realtime transcription socket through Workshop while Gateway remains responsible for authentication and the fixed upstream target. Relay payload and close frames without interpreting content, terminate control frames per hop, and bound peer cleanup. - `DEPENDENCY_PHASE` advances the exact dependency policy to Phase B and permits Workshop to depend on shared loopback validation. - `connect_socket` centralizes authenticated WebSocket setup for legacy STT and Realtime, with `workshop_status` selecting the legacy status header. - `routes` rejects missing or cross-origin authority and any requested subprotocol before it opens the fixed transcription connection. - `relay` forwards text, binary, and close frames, handles ping and pong per transport hop, and applies a 500 millisecond deadline to relay I/O and cleanup. - `realtime_relay_is_authenticated_fixed_and_payload_opaque` pins bearer ownership, the fixed upstream target, opaque payloads, hop-local control frames, close propagation, bounded disconnect cleanup, and browser handshake policy. Design: new flag-parameter @ crates/workshop-server/src/gateway/socket.rs::GatewayClient::connect_socket Design: new hidden-dependency @ crates/workshop-server/src/gateway/socket.rs::GatewayClient::connect_realtime Design: new surface-growth @ crates/workshop-server/src/routes/realtime.rs::routes deps: AppState boundary: wire Design: new pure-function @ crates/workshop-server/src/routes/realtime.rs::same_origin_allowed deps: HeaderMap,Uri boundary: wire Design: new pure-function @ crates/workshop-server/src/routes/realtime.rs::single_header deps: HeaderMap,HeaderName boundary: wire Design: new value-object @ crates/workshop-server/tests/it/realtime_relay.rs::UpstreamRequest boundary: wire Design: new shared-mutable-state @ crates/workshop-server/tests/it/realtime_relay.rs::UpstreamProbe Design: new oversized-unit @ crates/workshop-server/tests/it/realtime_relay.rs::upstream deps: HeaderMap,State,Uri,WebSocketUpgrade boundary: wire Design: new oversized-unit @ crates/workshop-server/tests/it/realtime_relay.rs::realtime_relay_is_authenticated_fixed_and_payload_opaque boundary: wire Design: new shared-mutable-state @ crates/workshop-server/tests/it/realtime_relay.rs::StalledPeerProbe Design: new pure-function @ crates/workshop-server/tests/it/realtime_relay.rs::request_with deps: Option<&str>,Option<&str>,str boundary: wire Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- Cargo.lock | 1 + crates/gateway-stt/tests/it/architecture.rs | 24 +- crates/workshop-server/Cargo.toml | 1 + crates/workshop-server/module-ceilings.toml | 12 +- crates/workshop-server/src/app.rs | 1 + crates/workshop-server/src/gateway.rs | 64 +-- crates/workshop-server/src/gateway/socket.rs | 94 ++++ crates/workshop-server/src/routes.rs | 1 + crates/workshop-server/src/routes/realtime.rs | 181 +++++++ crates/workshop-server/tests/it/main.rs | 1 + .../tests/it/realtime_relay.rs | 475 ++++++++++++++++++ vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- vibe/archdoc-next.md | 5 + 13 files changed, 793 insertions(+), 69 deletions(-) create mode 100644 crates/workshop-server/src/gateway/socket.rs create mode 100644 crates/workshop-server/src/routes/realtime.rs create mode 100644 crates/workshop-server/tests/it/realtime_relay.rs diff --git a/Cargo.lock b/Cargo.lock index 7bd5baf9..17a48cf6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8324,6 +8324,7 @@ dependencies = [ "rust-embed", "serde", "serde_json", + "shared-loopback", "shared-progress", "shared-sidecar", "socket2", diff --git a/crates/gateway-stt/tests/it/architecture.rs b/crates/gateway-stt/tests/it/architecture.rs index 3b399169..848a798c 100644 --- a/crates/gateway-stt/tests/it/architecture.rs +++ b/crates/gateway-stt/tests/it/architecture.rs @@ -20,7 +20,8 @@ const PUBLIC_ROOT_BUDGETS: [(&str, usize); 4] = [ ("gateway-whisper-ffi", 6), ]; -const PHASE_A_CRATES: [&str; 7] = [ +const DEPENDENCY_PHASE: &str = "Phase B"; +const DEPENDENCY_POLICY_CRATES: [&str; 7] = [ "gateway", "gateway-stt", "gateway-stt-engine", @@ -118,6 +119,7 @@ const DEPENDENCY_POLICIES: [DependencyPolicy; 7] = [ "promptforge-model-client", "promptforge-store", "promptforge-tools", + "shared-loopback", "shared-progress", "shared-sidecar", ], @@ -281,7 +283,9 @@ fn crate_workspace_edges(metadata: &CargoMetadata, crate_name: &str) -> BTreeSet } fn validate_dependency_policies(policies: &[DependencyPolicy]) -> Result<(), String> { - let expected = PHASE_A_CRATES.into_iter().collect::>(); + let expected = DEPENDENCY_POLICY_CRATES + .into_iter() + .collect::>(); let actual = policies .iter() .map(|policy| policy.crate_name) @@ -291,7 +295,8 @@ fn validate_dependency_policies(policies: &[DependencyPolicy]) -> Result<(), Str } if actual != expected { return Err(format!( - "dependency policies must cover the exact Phase A crates: expected {expected:?}, got {actual:?}" + "dependency policies must cover the exact {DEPENDENCY_PHASE} crates: expected \ + {expected:?}, got {actual:?}" )); } Ok(()) @@ -334,6 +339,19 @@ fn dependency_policy_omission_is_rejected() { ); } +#[test] +fn dependency_policy_has_advanced_to_phase_b() { + assert_eq!(DEPENDENCY_PHASE, "Phase B"); + let workshop = DEPENDENCY_POLICIES + .iter() + .find(|policy| policy.crate_name == "workshop-server") + .unwrap_or_else(|| panic!("Phase B contains the Workshop dependency policy")); + assert!( + workshop.final_edges.contains(&"shared-loopback"), + "Phase B adds only the Workshop dependency on shared-loopback" + ); +} + #[test] fn metadata_edges_include_renames_local_paths_targets_and_all_kinds() { let fixture = r#" diff --git a/crates/workshop-server/Cargo.toml b/crates/workshop-server/Cargo.toml index 3a159016..09f40ba0 100644 --- a/crates/workshop-server/Cargo.toml +++ b/crates/workshop-server/Cargo.toml @@ -31,6 +31,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/module-ceilings.toml b/crates/workshop-server/module-ceilings.toml index 8df8614c..cb4ae737 100644 --- a/crates/workshop-server/module-ceilings.toml +++ b/crates/workshop-server/module-ceilings.toml @@ -58,9 +58,12 @@ # `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 +# Split from gateway.rs: fixed-target authenticated WebSocket connections +# for the legacy and Realtime relay paths. +"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. @@ -144,6 +147,9 @@ "routes/gateway_config.rs" = 190 "routes/gateway_config/tests.rs" = 243 "routes/health.rs" = 50 +# New module: the same-origin, payload-opaque Realtime transcription relay, +# including bounded transport and hop-local control-frame ownership. +"routes/realtime.rs" = 181 # New module: the same-origin capability and WebSocket relay from the # Workshop listener to the gateway-owned STT routes. "routes/stt.rs" = 298 diff --git a/crates/workshop-server/src/app.rs b/crates/workshop-server/src/app.rs index 7733cb16..e4b6d8a0 100644 --- a/crates/workshop-server/src/app.rs +++ b/crates/workshop-server/src/app.rs @@ -217,6 +217,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::realtime::routes(state.clone())) .merge(routes::stt::routes(state.clone())) .merge(routes::gateway_config::routes(state)) .merge(with_deadline( diff --git a/crates/workshop-server/src/gateway.rs b/crates/workshop-server/src/gateway.rs index ae2fc262..14c0401d 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, GatewaySttSocket}; /// Default bound on a single `GET /health` probe: a gateway that accepts /// the connection but never answers must still read as unreachable, and two @@ -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..79e1d582 --- /dev/null +++ b/crates/workshop-server/src/gateway/socket.rs @@ -0,0 +1,94 @@ +//! 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 the gateway's STT stream. +pub(crate) type GatewaySttSocket = GatewaySocket; + +/// An authenticated WebSocket connection to Gateway Realtime transcription. +pub(crate) type GatewayRealtimeSocket = GatewaySocket; + +impl GatewayClient { + /// 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 { + self.connect_socket("/stt", None, true).await + } + + /// 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("/v1/realtime", Some("intent=transcription"), false) + .await + } + + async fn connect_socket( + &self, + endpoint: &str, + query: Option<&str>, + workshop_status: bool, + ) -> 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!("{}{endpoint}", url.path().trim_end_matches('/')); + url.set_path(&path); + url.set_query(query); + 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, + ); + } + if workshop_status { + 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))), + } + } +} diff --git a/crates/workshop-server/src/routes.rs b/crates/workshop-server/src/routes.rs index 990d30c1..12c8226d 100644 --- a/crates/workshop-server/src/routes.rs +++ b/crates/workshop-server/src/routes.rs @@ -5,5 +5,6 @@ pub(crate) mod assets; pub(crate) mod chat; pub(crate) mod gateway_config; pub(crate) mod health; +pub(crate) mod realtime; pub(crate) mod stt; pub(crate) mod workspace; diff --git a/crates/workshop-server/src/routes/realtime.rs b/crates/workshop-server/src/routes/realtime.rs new file mode 100644 index 00000000..e62fb283 --- /dev/null +++ b/crates/workshop-server/src/routes/realtime.rs @@ -0,0 +1,181 @@ +//! 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(); + } + match state.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/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..14ff67a8 --- /dev/null +++ b/crates/workshop-server/tests/it/realtime_relay.rs @@ -0,0 +1,475 @@ +//! 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, +} + +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 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" + ); +} + +#[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" + ); +} + +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(); + } + }) +} + +#[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 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" + ); +} + +#[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())) + ); +} + +#[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"); +} + +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") +} + +#[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/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 344269a1..761cbf9b 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -644,7 +644,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index a8bd5ef7..43cfa9ce 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -172,3 +172,8 @@ N43 | observation | shared-parameter-cluster @ crates/gateway/src/lib.rs::commit 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 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 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 +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 +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 +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 +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 From d17d168a7f0d7d3da5255a4a1fd297d39e7880cd Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 17:47:18 -0700 Subject: [PATCH 37/86] Implement recoverable browser speech capture Add a browser-owned 24 kHz microphone lifecycle that emits transferable PCM16 chunks and reports permission, device, start, stop, and clear failures as recoverable outcomes. Encode exact little-endian samples in the audio worklet, retain legacy float capture, and flush carried samples before graph teardown. - `SpeechCaptureBackend` isolates browser audio graph creation behind an injected session boundary, while `SpeechCaptureService` owns lifecycle phase and emitted audio state. - `Pcm16CaptureProcessor` clips float samples, encodes signed little-endian PCM16, chunks output, and accepts `clear` and `flush` commands. - `pcm-worklet.mjs` loads the production worklet and pins fixture parity, clipping, transfer ownership, carry, clear, flush, and sample-rate rejection. `speech-capture.mjs` pins browser graph ownership, recoverable failures, disposal, and resource cleanup. - `SpeechCaptureService` has no production caller in this change. Design: new message-passing @ crates/workshop-server/ui/pcm-worklet.js::Pcm16CaptureProcessor boundary: wire Design: new dispatch-on-tag @ crates/workshop-server/ui/pcm-worklet.js::Pcm16CaptureProcessor boundary: wire Design: new surface-growth @ crates/workshop-server/ui/pcm-worklet.js::Pcm16CaptureProcessor boundary: pub Design: new surface-growth @ crates/workshop-server/ui/src/services/speech-capture.ts boundary: pub Design: new speculative-abstraction @ crates/workshop-server/ui/src/services/speech-capture.ts::SpeechCaptureBackend Design: new constructor-injection @ crates/workshop-server/ui/src/services/speech-capture.ts::SpeechCaptureService Design: new temporal-coupling @ crates/workshop-server/ui/src/services/speech-capture.ts::SpeechCaptureService Design: new event-hook @ crates/workshop-server/ui/src/services/speech-capture.ts::SpeechCaptureService::onAudio boundary: pub Deferred: Workshop browser integration remains unwired Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- crates/workshop-server/ui/pcm-worklet.js | 70 ++- .../ui/src/services/speech-capture.ts | 342 +++++++++++++ .../workshop-server/ui/test/pcm-worklet.mjs | 130 +++++ .../ui/test/speech-capture.mjs | 448 ++++++++++++++++++ vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- 5 files changed, 985 insertions(+), 7 deletions(-) create mode 100644 crates/workshop-server/ui/src/services/speech-capture.ts create mode 100644 crates/workshop-server/ui/test/pcm-worklet.mjs create mode 100644 crates/workshop-server/ui/test/speech-capture.mjs diff --git a/crates/workshop-server/ui/pcm-worklet.js b/crates/workshop-server/ui/pcm-worklet.js index 9364ca2a..887ed1e6 100644 --- a/crates/workshop-server/ui/pcm-worklet.js +++ b/crates/workshop-server/ui/pcm-worklet.js @@ -1,20 +1,78 @@ "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. +const OUTPUT_SAMPLE_RATE = 24_000; +const DEFAULT_CHUNK_SAMPLES = OUTPUT_SAMPLE_RATE / 10; + +// Legacy /stt capture remains 16 kHz mono f32 until its consumer migrates. class PcmCaptureProcessor extends AudioWorkletProcessor { 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]); } - // No output is written; the node renders silence into the graph. + return true; + } +} + +// 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(`pcm-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) { + 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); + } + } + } return true; } } registerProcessor("pcm-capture", PcmCaptureProcessor); +registerProcessor("pcm16-capture", Pcm16CaptureProcessor); 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/test/pcm-worklet.mjs b/crates/workshop-server/ui/test/pcm-worklet.mjs new file mode 100644 index 00000000..a40904e9 --- /dev/null +++ b/crates/workshop-server/ui/test/pcm-worklet.mjs @@ -0,0 +1,130 @@ +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()], ["pcm-capture", "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 legacy processor keeps sending copied 16 kHz float blocks", async () => { + const { processor, messages } = await loadProcessor("pcm-capture", {}, 16_000); + const input = Float32Array.from([-0.5, 0, 0.75]); + + processor.process([[input]]); + input.fill(1); + + assert.equal(messages.length, 1); + assert.equal(Object.prototype.toString.call(messages[0].value), "[object ArrayBuffer]"); + assert.equal(messages[0].transfer[0], messages[0].value); + assert.deepEqual([...new Float32Array(messages[0].value)], [-0.5, 0, 0.75]); +}); + +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/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/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 761cbf9b..ccd22f95 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -653,7 +653,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. From 7452751bc29b710bd1c3726f623f3698d5d71c7c Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 18:13:22 -0700 Subject: [PATCH 38/86] Migrate Workshop dictation to Realtime Route browser dictation through a reusable Realtime transcription connection and production microphone capture. Keep concurrent takes isolated by item and client event, replace hypotheses in place, and restore local text on recoverable failures. - `speechCapture` gives all agent panels one composition-root microphone owner, while `AgentSessionView` receives that owner through the panel service chain. - `RealtimeTranscriptionService` negotiates the hypothesis extension, emits canonical append, commit, and clear events, validates server event shapes, and converts failures to local typed events. - `setupStt` preserves each selected range, waits for carried audio before commit, binds acknowledgments to queued takes, and applies overlapping results by item. - `setupLegacyStt` remains private until installed-package acceptance permits removal of the old browser protocol. Design: new shared-mutable-state @ crates/workshop-server/ui/src/main.ts::speechCapture Design: new surface-growth @ crates/workshop-server/ui/src/services/realtime-transcription.ts boundary: pub Design: new event-hook @ crates/workshop-server/ui/src/services/realtime-transcription.ts::RealtimeTranscriptionService Design: new temporal-coupling @ crates/workshop-server/ui/src/services/realtime-transcription.ts::RealtimeTranscriptionService Design: new oversized-unit @ crates/workshop-server/ui/src/services/realtime-transcription.ts::RealtimeTranscriptionService Design: new hidden-dependency @ crates/workshop-server/ui/src/services/realtime-transcription.ts::socketUrl Design: new dispatch-on-tag @ crates/workshop-server/ui/src/services/realtime-transcription.ts::handleMessage Design: new parallel-abstraction @ crates/workshop-server/ui/src/ui/realtime-stt.ts::Take Design: new shared-parameter-cluster @ crates/workshop-server/ui/src/ui/realtime-stt.ts::setupStt deps: RealtimeTranscriptionService,SpeechCaptureService,SttBlocker,SttElements,SttStatus boundary: pub Design: new oversized-unit @ crates/workshop-server/ui/src/ui/realtime-stt.ts::setupStt deps: RealtimeTranscriptionService,SpeechCaptureService,SttBlocker,SttElements,SttStatus boundary: pub Design: new surface-growth @ crates/workshop-server/ui/src/ui/stt.ts::SttInputTarget boundary: pub Design: new constructor-injection @ crates/workshop-server/ui/src/ui/agent-session-view.ts::AgentSessionView Deferred: Remove the legacy Workshop speech path after installed-package acceptance. Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- crates/workshop-server/ui/src/main.ts | 6 +- .../ui/src/services/realtime-transcription.ts | 367 ++++++++++++++ .../ui/src/ui/agent-session-view.ts | 40 +- .../workshop-server/ui/src/ui/prompt-input.ts | 5 + .../workshop-server/ui/src/ui/realtime-stt.ts | 355 +++++++++++++ crates/workshop-server/ui/src/ui/stt.ts | 8 +- .../ui/src/ui/workshop/agent-panel.ts | 6 +- .../ui/src/ui/workshop/panel-types.ts | 4 +- .../ui/test/agent-stt-boot.mjs | 57 ++- crates/workshop-server/ui/test/agent-stt.mjs | 475 ++++++++++++++---- .../workshop-server/ui/test/helpers/boot.mjs | 48 +- .../workshop-server/ui/test/prompt-input.mjs | 1 + crates/workshop-server/ui/test/stt-stream.mjs | 266 ++++------ vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- vibe/archdoc-next.md | 7 + 15 files changed, 1331 insertions(+), 316 deletions(-) create mode 100644 crates/workshop-server/ui/src/services/realtime-transcription.ts create mode 100644 crates/workshop-server/ui/src/ui/realtime-stt.ts 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/realtime-transcription.ts b/crates/workshop-server/ui/src/services/realtime-transcription.ts new file mode 100644 index 00000000..267b1553 --- /dev/null +++ b/crates/workshop-server/ui/src/services/realtime-transcription.ts @@ -0,0 +1,367 @@ +import { Emitter, type Event as ServiceEvent } from "../base/event"; +import { Disposable } from "../base/lifecycle"; + +const HYPOTHESIS_INCLUDE = "item.input_audio_transcription.hypothesis"; + +/** Readiness of the browser's Realtime transcription connection. */ +export type RealtimeTranscriptionState = "connecting" | "ready" | "unavailable"; + +/** A complete replacement snapshot for one committed audio item. */ +export interface RealtimeTranscriptSnapshot { + readonly itemId: string; + readonly text: string; +} + +/** The authoritative transcript for one committed audio item. */ +export interface RealtimeTranscriptCompletion { + readonly itemId: string; + readonly transcript: string; +} + +/** A recoverable terminal failure for one committed audio item. */ +export interface RealtimeTranscriptFailure { + readonly itemId: string; + readonly code: string; +} + +/** A recoverable connection, session, or client-event failure. */ +export interface RealtimeTranscriptionError { + readonly code: string; + readonly scope: "connection" | "session" | "event"; + 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 objectValue(value: unknown): Record | null { + return typeof value === "object" && value !== null + ? (value as Record) + : null; +} + +function nonemptyString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +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 exposes item-keyed replacement snapshots so + * views never need to interpret wire deltas or server-authored status text. + */ +export class RealtimeTranscriptionService extends Disposable { + private readonly stateEmitter = this._register(new Emitter()); + private readonly committedEmitter = this._register(new Emitter()); + private readonly snapshotEmitter = this._register(new Emitter()); + private readonly completedEmitter = this._register( + new Emitter(), + ); + private readonly failedEmitter = this._register(new Emitter()); + private readonly errorEmitter = this._register(new Emitter()); + private readonly deltas = new Map(); + private socket: RealtimeSocket | null = null; + private disposed = false; + private negotiatedHypotheses = false; + private currentState: RealtimeTranscriptionState = "connecting"; + + /** Fires when connection readiness changes. */ + readonly onState: ServiceEvent = this.stateEmitter.event; + /** Fires when the server assigns an item ID to the oldest committed take. */ + readonly onCommitted: ServiceEvent = this.committedEmitter.event; + /** Fires complete replacement text for one item. */ + readonly onSnapshot: ServiceEvent = this.snapshotEmitter.event; + /** Fires the authoritative completion for one item. */ + readonly onCompleted: ServiceEvent = this.completedEmitter.event; + /** Fires a recoverable item-scoped failure. */ + readonly onFailed: ServiceEvent = this.failedEmitter.event; + /** Fires a recoverable connection, protocol, or unscoped 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"); + 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.reportError("connection_failed"); + } + }; + const onClose = (): void => { + if (this.socket !== socket) { + return; + } + this.socket = null; + this.negotiatedHypotheses = false; + this.deltas.clear(); + if (!this.disposed) { + this.setState("unavailable"); + this.reportError("connection_closed"); + } + }; + 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; + const socket = this.socket; + this.socket = null; + socket?.close(); + this.deltas.clear(); + 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 = objectValue(parsed); + const type = nonemptyString(event?.type); + if (event === null || type === null) { + this.reportError("invalid_server_event", "session"); + return; + } + + if (type === "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; + } + if (type === "session.updated") { + const session = objectValue(event.session); + const include = session?.include; + this.negotiatedHypotheses = + Array.isArray(include) && + include.length === 1 && + include[0] === HYPOTHESIS_INCLUDE; + this.setState("ready"); + return; + } + if (type === "input_audio_buffer.committed") { + const itemId = nonemptyString(event.item_id); + if (itemId !== null) { + this.committedEmitter.fire(itemId); + } else { + this.reportError("invalid_server_event", "session"); + } + return; + } + if (type === "conversation.item.input_audio_transcription.hypothesis") { + const itemId = nonemptyString(event.item_id); + if (itemId !== null && typeof event.transcript === "string") { + this.snapshotEmitter.fire({ itemId, text: event.transcript }); + } else { + this.reportError("invalid_server_event", "session"); + } + return; + } + if (type === "conversation.item.input_audio_transcription.delta") { + if (this.negotiatedHypotheses) { + return; + } + const itemId = nonemptyString(event.item_id); + if (itemId !== null && typeof event.delta === "string") { + const text = (this.deltas.get(itemId) ?? "") + event.delta; + this.deltas.set(itemId, text); + this.snapshotEmitter.fire({ itemId, text }); + } else { + this.reportError("invalid_server_event", "session"); + } + return; + } + if (type === "conversation.item.input_audio_transcription.completed") { + const itemId = nonemptyString(event.item_id); + if (itemId !== null && typeof event.transcript === "string") { + this.deltas.delete(itemId); + this.completedEmitter.fire({ itemId, transcript: event.transcript }); + } else { + this.reportError("invalid_server_event", "session"); + } + return; + } + if (type === "conversation.item.input_audio_transcription.failed") { + const itemId = nonemptyString(event.item_id); + const error = objectValue(event.error); + if (itemId !== null && error !== null) { + this.deltas.delete(itemId); + this.failedEmitter.fire({ + itemId, + code: nonemptyString(error.code) ?? "transcription_failed", + }); + } else { + this.reportError("invalid_server_event", "session"); + } + return; + } + if (type === "error") { + const error = objectValue(event.error); + const eventId = nonemptyString(error?.event_id); + this.reportError( + nonemptyString(error?.code) ?? "server_error", + eventId === null ? "session" : "event", + eventId, + ); + } + } + + 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 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/ui/agent-session-view.ts b/crates/workshop-server/ui/src/ui/agent-session-view.ts index 908924b0..904e3b65 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"; @@ -191,13 +189,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, + speechCapture?: SpeechCaptureService, ) { super(); this.element = document.createElement("section"); @@ -258,39 +255,24 @@ export class AgentSessionView extends Disposable { // 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; - }); } /** diff --git a/crates/workshop-server/ui/src/ui/prompt-input.ts b/crates/workshop-server/ui/src/ui/prompt-input.ts index 67364e6a..8cf7ed29 100644 --- a/crates/workshop-server/ui/src/ui/prompt-input.ts +++ b/crates/workshop-server/ui/src/ui/prompt-input.ts @@ -250,6 +250,11 @@ export class PromptInput extends Disposable implements SttInputTarget { .run(); } + /** Reads plain text from one ProseMirror range for reversible dictation. */ + readRange(from: number, to: number): string { + return this.editor.state.doc.textBetween(from, to, "\n", "\n"); + } + /** * The dictation take's lock: non-editable plus the recording ring on * the frame (stt.css's `.stt-input--recording`). Composes with the 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..0e21383d --- /dev/null +++ b/crates/workshop-server/ui/src/ui/realtime-stt.ts @@ -0,0 +1,355 @@ +import { DisposableStore, toDisposable } from "../base/lifecycle"; +import { + RealtimeTranscriptionService, + type RealtimeTranscriptCompletion, + type RealtimeTranscriptSnapshot, +} from "../services/realtime-transcription"; +import { + SpeechCaptureService, + type SpeechCaptureFailure, + type SpeechCaptureOutcome, +} from "../services/speech-capture"; +import type { + SttBlocker, + SttElements, + SttHandle, + SttStatus, +} from "./stt"; + +interface Take { + from: number; + length: number; + readonly original: string; + itemId: string | null; +} + +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. Item-keyed take regions isolate overlapping authoritative results. + */ +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()); + const takes: Take[] = []; + const awaitingCommit: Array = []; + const byItem = new Map(); + const byClientEvent = new Map(); + let active: Take | null = null; + let stopping = false; + 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 || (active === null && capture.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 syncInputLock(): void { + input.setReadOnly(takes.length > 0); + } + + function splice(take: Take, text: string): void { + const oldEnd = take.from + take.length; + const delta = text.length - take.length; + input.replaceRange(take.from, oldEnd, text); + take.length = text.length; + if (delta === 0) { + return; + } + for (const other of takes) { + if (other !== take && other.from >= oldEnd) { + other.from += delta; + } + } + } + + function removeTake(take: Take): void { + const index = takes.indexOf(take); + if (index >= 0) { + takes.splice(index, 1); + } + const waiting = awaitingCommit.indexOf(take); + if (waiting >= 0) { + awaitingCommit[waiting] = null; + } + if (take.itemId !== null) { + byItem.delete(take.itemId); + } + if (active === take) { + active = null; + } + for (const [eventId, owner] of byClientEvent) { + if (owner === take) { + byClientEvent.delete(eventId); + } + } + syncInputLock(); + } + + function rollback(take: Take): void { + splice(take, take.original); + removeTake(take); + } + + function rollbackAll(): void { + for (const take of [...takes].reverse()) { + rollback(take); + } + } + + function takeFor(itemId: string): Take | null { + return byItem.get(itemId) ?? null; + } + + function applySnapshot(snapshot: RealtimeTranscriptSnapshot): void { + const take = takeFor(snapshot.itemId); + if (take !== null) { + splice(take, snapshot.text); + } + } + + function applyCompletion(completion: RealtimeTranscriptCompletion): void { + const take = takeFor(completion.itemId); + if (take === null) { + return; + } + if (active === take && capture.recording) { + active = null; + void releaseCapture(); + setRecording(false); + } + const transcript = completion.transcript.trimEnd(); + splice(take, transcript); + removeTake(take); + if (transcript === "") { + status.showLocal("No speech was detected.", "info"); + } else { + input.focus(); + status.showLocal("Dictation ready.", "info"); + } + } + + store.add( + realtime.onCommitted((itemId) => { + const known = byItem.get(itemId); + if (known !== undefined) { + const index = awaitingCommit.indexOf(known); + if (index >= 0) { + awaitingCommit.splice(index, 1); + } + return; + } + const take = awaitingCommit.length > 0 ? awaitingCommit.shift() : active; + if (take === undefined || take === null) { + return; + } + take.itemId = itemId; + byItem.set(itemId, take); + }), + ); + store.add(realtime.onSnapshot(applySnapshot)); + store.add(realtime.onCompleted(applyCompletion)); + store.add( + realtime.onFailed(({ itemId }) => { + const take = takeFor(itemId); + if (take !== null) { + rollback(take); + } + status.showLocal("Dictation could not be transcribed. Try again.", "error"); + }), + ); + store.add( + realtime.onError((error) => { + if (error.scope === "connection") { + if (takes.length > 0 && active !== null && capture.recording) { + capture.clear(); + void releaseCapture(); + } + rollbackAll(); + awaitingCommit.length = 0; + setRecording(false); + } else { + const affected = + error.scope === "event" && error.eventId !== null + ? byClientEvent.get(error.eventId) ?? null + : active; + if (affected !== null) { + if (active === affected && capture.recording) { + capture.clear(); + void releaseCapture(); + } + rollback(affected); + setRecording(false); + } + } + status.showLocal("Dictation is temporarily unavailable. Try again.", "error"); + }), + ); + store.add( + capture.onAudio((chunk) => { + const take = active; + if (take === null) { + return; + } + const eventId = realtime.append(chunk); + if (eventId === null) { + capture.clear(); + void releaseCapture(); + if (takes.includes(take)) { + rollback(take); + } + setRecording(false); + } else { + byClientEvent.set(eventId, take); + } + }), + ); + + async function start(): Promise { + const reason = blocked(); + if (reason !== null) { + status.showLocal(reason, "info"); + return; + } + if (pendingCaptureStop !== null) { + await pendingCaptureStop; + if (disposed || active !== null) { + return; + } + } + if (realtime.state !== "ready") { + realtime.connect(); + status.showLocal("Dictation is connecting. Try again in a moment.", "info"); + return; + } + const selection = input.getSelection(); + const outcome = await capture.start(); + if (!outcome.ok) { + status.showLocal(captureFailureLabel(outcome), "error"); + return; + } + if (disposed) { + void releaseCapture(); + return; + } + const take: Take = { + from: selection.start, + length: selection.end - selection.start, + original: input.readRange(selection.start, selection.end), + itemId: null, + }; + takes.push(take); + active = take; + syncInputLock(); + setRecording(true); + status.showLocal("Listening...", "info"); + } + + async function stop(): Promise { + const take = active; + if (take === null || stopping) { + return; + } + stopping = true; + const stoppingCapture = releaseCapture(); + setRecording(false); + status.showLocal("Transcribing...", "info"); + const outcome = await stoppingCapture; + if (active === take) { + active = null; + } + stopping = false; + if (disposed) { + return; + } + if (!takes.includes(take)) { + return; + } + if (!outcome.ok) { + realtime.clear(); + rollback(take); + status.showLocal(captureFailureLabel(outcome), "error"); + return; + } + const eventId = realtime.commit(); + if (eventId === null) { + rollback(take); + return; + } + awaitingCommit.push(take); + byClientEvent.set(eventId, take); + } + + function discardIfRecording(): void { + if (takes.length === 0) { + return; + } + if (active !== null && capture.recording) { + capture.clear(); + realtime.clear(); + void releaseCapture(); + } + active = null; + stopping = false; + rollbackAll(); + setRecording(false); + } + + const onMicClick = (): void => { + if (active !== null) { + void 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..b275c222 100644 --- a/crates/workshop-server/ui/src/ui/stt.ts +++ b/crates/workshop-server/ui/src/ui/stt.ts @@ -13,6 +13,7 @@ import "./stt.css"; import { DisposableStore, toDisposable, type IDisposable } from "../base/lifecycle"; +export { setupStt } from "./realtime-stt"; /** * What dictation needs from its host input: a text target the take can @@ -27,6 +28,8 @@ export interface SttInputTarget { getSelection(): { start: number; end: number }; /** Replaces [from, to] with text, leaving the cursor after the inserted text. */ replaceRange(from: number, to: number, text: string): void; + /** Reads the plain text currently occupying [from, to]. */ + readRange(from: number, to: number): string; /** Locks the input against typing while a take splices, or releases it. */ setReadOnly(readOnly: boolean): void; /** Returns focus to the input; a landed final calls it. */ @@ -55,6 +58,7 @@ export function textareaSttTarget(input: HTMLTextAreaElement): SttInputTarget { // behaves like typing to whatever listens on the input. input.dispatchEvent(new Event("input", { bubbles: true })); }, + readRange: (from, to) => input.value.slice(from, to), setReadOnly: (readOnly) => { input.readOnly = readOnly; input.classList.toggle("stt-input--recording", readOnly); @@ -145,7 +149,9 @@ interface StreamTracker { current: number | null; } -export function setupStt( +// Retained with the legacy capability seam until installed-package speech +// acceptance permits Step 32 to delete the old browser protocol in one pass. +function setupLegacyStt( elements: SttElements, statusBar: SttStatus, blocked: SttBlocker, 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-stt-boot.mjs b/crates/workshop-server/ui/test/agent-stt-boot.mjs index 8dfeb235..0bfe623d 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); } @@ -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}")`); } diff --git a/crates/workshop-server/ui/test/agent-stt.mjs b/crates/workshop-server/ui/test/agent-stt.mjs index de124808..82f5c005 100644 --- a/crates/workshop-server/ui/test/agent-stt.mjs +++ b/crates/workshop-server/ui/test/agent-stt.mjs @@ -1,13 +1,9 @@ // 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 +// 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 { writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -73,6 +69,7 @@ globalThis.navigator.mediaDevices = { }; class FakeAudioContext { constructor() { + this.sampleRate = 24_000; this.destination = {}; this.audioWorklet = { addModule: () => Promise.resolve() }; } @@ -82,10 +79,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() {} @@ -97,6 +112,7 @@ globalThis.AudioWorkletNode = FakeAudioWorkletNode; // A scripted /stt 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 +143,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 +153,53 @@ class FakeWebSocket { } // Test-side control, not part of the WebSocket surface. message(frame) { + if (frame.type === "interim" || 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 = + frame.type === "interim" + ? { + type: "conversation.item.input_audio_transcription.hypothesis", + event_id: `hypothesis_${nextItem}`, + item_id: this.itemId, + content_index: 0, + revision: 1, + transcript: [frame.committed, frame.tentative].filter(Boolean).join( + frame.committed && frame.tentative && !/\s$/.test(frame.committed) ? " " : "", + ), + finalized: frame.committed ?? "", + agreed: "", + tentative: frame.tentative ?? "", + audio_start_ms: 0, + audio_end_ms: 100, + } + : { + 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 +213,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 +258,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 +274,30 @@ 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({ + type: "session.created", + event_id: "created", + session: { id: "session", object: "realtime.transcription_session", type: "transcription", include: [], audio: { input: {} } }, + }); + await waitFor(() => realtime.sent.some((event) => event.type === "session.update")); + realtime.message({ + type: "session.updated", + event_id: "updated", + session: { + id: "session", + object: "realtime.transcription_session", + type: "transcription", + include: ["item.input_audio_transcription.hypothesis"], + audio: { input: {} }, + }, + }); 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 @@ -266,12 +311,9 @@ async function harness(capability = { gpu: true, engine: true }) { // Clicks the mic and waits for the take's /stt 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(); @@ -320,7 +362,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 +374,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; @@ -428,14 +470,14 @@ await assertNoLeaks(lifecycle, async () => { ); 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(); @@ -468,7 +510,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()); @@ -491,7 +537,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 +551,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 +573,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(); } @@ -548,7 +623,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 +641,268 @@ 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(); } - // --- The capability probe gates the mic ------------------------------------ + // 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() === "", + ); - 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); + 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: "current_commit", + item_id: "current_item", + previous_item_id: "discarded_item", + }); + 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, + }); + check( + "the acknowledgment after a tombstone binds the current take", + input.getText() === "right take", + ); + dispose(); + } + + // --- Overlapping items finalize independently ------------------------------ + + { + 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: "input_audio_buffer.committed", + event_id: "overlap_commit_a", + item_id: "overlap_a", + previous_item_id: null, + }); + socket.message({ + type: "conversation.item.input_audio_transcription.hypothesis", + event_id: "overlap_hypothesis_a", + item_id: "overlap_a", + content_index: 0, + revision: 1, + transcript: "first", + finalized: "fir", + agreed: "s", + tentative: "t", + 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, + ); + socket.message({ + type: "input_audio_buffer.committed", + event_id: "overlap_commit_b", + item_id: "overlap_b", + previous_item_id: "overlap_a", + }); + socket.message({ + type: "conversation.item.input_audio_transcription.hypothesis", + event_id: "overlap_hypothesis_b", + item_id: "overlap_b", + content_index: 0, + revision: 1, + transcript: " second", + finalized: " sec", + agreed: "on", + tentative: "d", + audio_start_ms: 100, + audio_end_ms: 200, + }); 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 [itemId, transcript] of [ + ["overlap_b", " SECOND"], + ["overlap_a", "FIRST LONG"], + ]) { + socket.message({ + type: "conversation.item.input_audio_transcription.completed", + event_id: `overlap_done_${itemId}`, + item_id: itemId, + content_index: 0, + transcript, + usage: { type: "duration", seconds: 0.1 }, + }); + } + check( + "reverse completion replaces each item with authoritative text", + input.getText() === "base FIRST LONG 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 commit rejection rolls back its take but preserves an older finalization", + typeof rejectedCommit.event_id === "string" && input.getText() === "older", + ); + 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( - "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", + "the preserved older item still accepts authoritative completion", + input.getText() === "OLDER FINAL", ); - answerPendingProbe({ gpu: true, engine: true }); - await sleep(10); + 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..03e922a9 100644 --- a/crates/workshop-server/ui/test/helpers/boot.mjs +++ b/crates/workshop-server/ui/test/helpers/boot.mjs @@ -90,6 +90,21 @@ 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: { + id: "boot_realtime", + object: "realtime.transcription_session", + type: "transcription", + include: [], + audio: { input: {} }, + }, + }), + }); + } }, 0); } addEventListener(type, listener) { @@ -99,6 +114,24 @@ 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: { + id: "boot_realtime", + object: "realtime.transcription_session", + type: "transcription", + include: ["item.input_audio_transcription.hypothesis"], + audio: { input: {} }, + }, + }), + }), + ); + } } close() { this.readyState = FakeWebSocket.CLOSED; @@ -115,6 +148,7 @@ export async function bootWorkbench(name, run) { }; class FakeAudioContext { constructor() { + this.sampleRate = 24_000; this.destination = {}; this.audioWorklet = { addModule: () => Promise.resolve() }; } @@ -124,10 +158,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() {} @@ -254,7 +298,7 @@ export async function bootWorkbench(name, run) { // The agent panel's session socket, and the per-take /stt 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/prompt-input.mjs b/crates/workshop-server/ui/test/prompt-input.mjs index 687e5db7..9df3789e 100644 --- a/crates/workshop-server/ui/test/prompt-input.mjs +++ b/crates/workshop-server/ui/test/prompt-input.mjs @@ -283,6 +283,7 @@ await assertNoLeaks(lifecycle, () => { const input = new PromptInput(); input.setText("ab"); check("setText loads plain text", input.getText() === "ab"); + check("readRange preserves the text a take may need to restore", input.readRange(1, 3) === "ab"); input.setSelection(2, 2); check( "setSelection places the cursor between the characters", diff --git a/crates/workshop-server/ui/test/stt-stream.mjs b/crates/workshop-server/ui/test/stt-stream.mjs index 36867472..4b9cddc4 100644 --- a/crates/workshop-server/ui/test/stt-stream.mjs +++ b/crates/workshop-server/ui/test/stt-stream.mjs @@ -1,26 +1,19 @@ -// 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 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 { setupStt, textareaSttTarget } from "./src/ui/stt.ts"; + export { RealtimeTranscriptionService } from "./src/services/realtime-transcription.ts"; `, resolveDir: path.join(uiDir, ".."), loader: "ts", @@ -34,74 +27,23 @@ 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 failures = []; -function check(name, condition) { - if (!condition) failures.push(name); -} - -// 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() {} -} -window.AudioContext = FakeAudioContext; -globalThis.AudioContext = FakeAudioContext; -globalThis.AudioWorkletNode = FakeAudioWorkletNode; +const { lifecycle, RealtimeTranscriptionService } = 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/"); -// 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 +58,88 @@ 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)); - } - return false; -} - -const statusBar = { showLocal() {}, setRecording() {} }; 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); - - // --- The stream frame sets the generation; matching frames apply ------- - - 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); - - // --- Tagged frames before any announcement are treated as current -------- - - 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", - ); - - handle.dispose(); - - // --- A blocked click names the reason and opens no socket --------------- - - 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.", - ); - 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", - ); - check( - "a blocked click opens no /stt socket", - sockets.length === 2, - ); - blockedHandle.dispose(); + 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 snapshots = []; + const completions = []; + const failures = []; + const errors = []; + service.onState((value) => states.push(value)); + service.onSnapshot((value) => snapshots.push(value)); + service.onCompleted((value) => completions.push(value)); + service.onFailed((value) => failures.push(value)); + service.onError((value) => errors.push(value)); + + 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"]); + + 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" }, + ]); + + 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(snapshots, [{ itemId: "item_alpha", text: "Hello, world" }]); + assert.deepEqual(completions, [{ itemId: "item_alpha", transcript: "Hello, world" }]); + assert.deepEqual(failures, [{ itemId: "item_beta", code: "transcription_failed" }]); + assert.deepEqual(errors, [ + { + code: "unsupported_model", + scope: "event", + eventId: "client_bad_update", + recoverable: true, + }, + ]); + + service.dispose(); + assert.equal(sockets[0].readyState, ScriptedSocket.CLOSED); }); - -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); diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index ccd22f95..c38ba572 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -662,7 +662,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 43cfa9ce..14fa9a42 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -177,3 +177,10 @@ N48 | observation | shared-mutable-state @ crates/workshop-server/tests/it/realt 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 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 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 +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 +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 +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 +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 From 6ce3872908cf308341b26ff7c108d7cc60909391 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 18:34:28 -0700 Subject: [PATCH 39/86] Prove Realtime flows with canonical fixtures Drive Gateway and Workshop Realtime tests from the same canonical event sequences without coupling their production servers. Cover hypotheses, completion, clear, overlap, saturation retry, relay bearer isolation, and replacement closure. Preserve insertion whitespace when authoritative completion replaces an empty selection. - `canonical_sequences` and `canonicalMessage` load the shared fixture contract into independent Rust and browser harnesses. - `FixtureUpstream` emits canonical server frames, accepts only the Gateway bearer, detects browser bearer leakage, and echoes opaque payloads. - `canonical_fixture_drives_hypothesis_completion_and_clear` and `saturated_commit_preserves_the_canonical_input_for_retry` pin fixture-driven Gateway behavior, including exact retry audio and lineage. - `applyCompletion` retains insertion whitespace when a completion replaces provisional text at an empty selection. - `crates/gateway/tests/it/realtime_stt.rs` and `crates/workshop-server/tests/it/realtime_relay.rs` remain independent harnesses; the change adds neither a dual-server test nor a package dependency. Design: new hidden-dependency @ crates/gateway/tests/it/realtime_stt.rs::canonical_sequences Design: new shared-parameter-cluster @ crates/gateway/tests/it/realtime_stt.rs::canonical_message deps: &serde_json::Value,&str,&str,&str,usize Design: new pure-function @ crates/gateway/tests/it/realtime_stt.rs::canonical_message deps: &serde_json::Value,&str,&str,&str,usize Design: new shared-parameter-cluster @ crates/gateway/tests/it/realtime_stt.rs::canonical_first_message deps: &serde_json::Value,&str,&str,&str Design: new pure-function @ crates/gateway/tests/it/realtime_stt.rs::canonical_first_message deps: &serde_json::Value,&str,&str,&str Design: new shared-parameter-cluster @ crates/gateway/tests/it/realtime_stt.rs::canonical_client deps: &serde_json::Value,&str,&str Design: new pure-function @ crates/gateway/tests/it/realtime_stt.rs::canonical_client deps: &serde_json::Value,&str,&str Design: new shared-parameter-cluster @ crates/gateway/tests/it/realtime_stt.rs::canonical_server deps: &serde_json::Value,&str,&str Design: new pure-function @ crates/gateway/tests/it/realtime_stt.rs::canonical_server deps: &serde_json::Value,&str,&str Design: extends oversized-unit @ crates/gateway/tests/it/realtime_stt.rs Design: new oversized-unit @ crates/gateway/tests/it/realtime_stt.rs::canonical_fixture_drives_hypothesis_completion_and_clear Design: new oversized-unit @ crates/gateway/tests/it/realtime_stt.rs::saturated_commit_preserves_the_canonical_input_for_retry Design: new pure-function @ crates/workshop-server/tests/it/realtime_relay.rs::canonical_server_frames Design: new shared-mutable-state @ crates/workshop-server/tests/it/realtime_relay.rs::FixtureUpstream Design: extends oversized-unit @ crates/workshop-server/ui/src/ui/realtime-stt.ts::setupStt deps: RealtimeTranscriptionService,SpeechCaptureService,SttBlocker,SttElements,SttStatus boundary: pub Design: new hidden-dependency @ crates/workshop-server/ui/test/agent-stt.mjs::canonicalMessage Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- crates/gateway/tests/it/realtime_stt.rs | 426 ++++++++++++++---- .../tests/it/realtime_relay.rs | 132 ++++++ .../workshop-server/ui/src/ui/realtime-stt.ts | 11 +- crates/workshop-server/ui/test/agent-stt.mjs | 187 ++++++-- vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- 5 files changed, 633 insertions(+), 125 deletions(-) diff --git a/crates/gateway/tests/it/realtime_stt.rs b/crates/gateway/tests/it/realtime_stt.rs index d54a75da..57e4f1ec 100644 --- a/crates/gateway/tests/it/realtime_stt.rs +++ b/crates/gateway/tests/it/realtime_stt.rs @@ -1,6 +1,7 @@ //! Mounted Realtime transcription route through the production Gateway wall. use std::net::SocketAddr; +use std::path::PathBuf; use std::time::Duration; use base64::Engine as _; @@ -150,6 +151,65 @@ fn audio() -> String { base64::engine::general_purpose::STANDARD.encode(bytes) } +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}"); @@ -173,6 +233,107 @@ async fn expect_error( event } +#[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; + + for _ in 0..2 { + let mut append = canonical_client( + &fixtures, + "hypothesis_negotiation", + "input_audio_buffer.append", + ); + append["audio"] = serde_json::json!(audio()); + send(&mut socket, append).await; + } + let first = expect_type( + &mut socket, + "conversation.item.input_audio_transcription.hypothesis", + ) + .await; + let second = expect_type( + &mut socket, + "conversation.item.input_audio_transcription.hypothesis", + ) + .await; + 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; +} + #[tokio::test] async fn gateway_auth_origin_query_and_legacy_surfaces_precede_upgrade() { let service = speech(&ScriptedDecoder::new(), Some(&ScriptedDecoder::new())); @@ -467,6 +628,7 @@ async fn admission_is_bounded_and_replacement_closes_with_1012() { panic!("replacement emits a close frame, got {message:?}"); }; assert_eq!(u16::from(close.code), 1012); + assert_eq!(close.reason, "engine_replaced"); drop(sockets); final_decoder.release(); @@ -687,6 +849,12 @@ async fn standard_interims_emit_only_appendable_agreed_deltas() { #[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, @@ -735,6 +903,9 @@ async fn mounted_terminal_failures_preserve_their_typed_wire_reason() { 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); @@ -742,101 +913,198 @@ async fn mounted_terminal_failures_preserve_their_typed_wire_reason() { } } -#[tokio::test] -async fn standard_result_capacity_rejects_before_audio_mutation_and_retries() { - let interim = ScriptedDecoder::new(); - interim.push_text("word0 alternative"); - for end in 1..=16 { - interim.push_text( - (0..=end) - .map(|index| format!("word{index}")) - .collect::>() - .join(" "), - ); - } - interim.push_text("retry"); - let final_decoder = ScriptedDecoder::new(); - final_decoder.push_text("authoritative"); - 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 _ in 0..17 { - send( - &mut socket, - serde_json::json!({ - "type": "input_audio_buffer.append", - "audio": audio() - }), - ) - .await; - } +async fn commit_existing_item( + socket: &mut Socket, + append: &serde_json::Value, + previous: Option<&String>, +) -> String { + send(socket, append.clone()).await; send( - &mut socket, - serde_json::json!({ - "type": "input_audio_buffer.append", - "event_id": "capacity-plus-one", - "audio": audio() - }), - ) - .await; - expect_error( - &mut socket, - "overload_error", - "result_queue_overload", - "The session result queue is full", - serde_json::Value::Null, - "capacity-plus-one", + 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!( - interim.requests().len(), - 17, - "rejected append starts no decode" + 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 +} - 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; - for _ in 0..16 { - expect_type( - &mut socket, - "conversation.item.input_audio_transcription.delta", +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(), + ); } - expect_type( - &mut socket, + 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; - let final_requests = final_decoder.requests(); - assert_eq!(final_requests.len(), 1); - assert_eq!( - final_requests[0].samples().len(), - 17 * 1_600, - "capacity-plus-one audio was not incorporated" + 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 append = canonical_client( + &fixtures, + "saturated_commit_retry", + "input_audio_buffer.append", ); + 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(); + final_decoder.park_next(); + 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; - send( - &mut socket, - serde_json::json!({ - "type": "input_audio_buffer.append", - "audio": audio() - }), - ) - .await; - let retried = interim.clone(); + 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); + } + let parked = final_decoder.clone(); assert!( - tokio::task::spawn_blocking(move || retried.wait_for_requests(18, PHASE_TIMEOUT)) + tokio::task::spawn_blocking(move || parked.wait_until_parked(PHASE_TIMEOUT)) .await - .expect("request observer joins"), - "append retries after committed deltas drain" + .expect("park observer joins"), + "four committed items remain outstanding behind the parked final worker" + ); + assert_eq!( + final_decoder.requests().len(), + 1, + "the serial final worker is parked while four items own finalization" + ); + + send(&mut socket, append.clone()).await; + send(&mut socket, commit).await; + let saturated = expect_type(&mut socket, "error").await; + let requests_at_saturation = final_decoder.requests().len(); + final_decoder.release(); + 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"); diff --git a/crates/workshop-server/tests/it/realtime_relay.rs b/crates/workshop-server/tests/it/realtime_relay.rs index 14ff67a8..63df5eeb 100644 --- a/crates/workshop-server/tests/it/realtime_relay.rs +++ b/crates/workshop-server/tests/it/realtime_relay.rs @@ -41,6 +41,41 @@ struct UpstreamProbe { 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 @@ -72,6 +107,53 @@ impl UpstreamProbe { } } +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, @@ -185,6 +267,56 @@ async fn assert_no_frame( ); } +#[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"); +} + #[tokio::test] async fn realtime_relay_is_authenticated_fixed_and_payload_opaque() { let (gateway, probe) = spawn_probe().await; diff --git a/crates/workshop-server/ui/src/ui/realtime-stt.ts b/crates/workshop-server/ui/src/ui/realtime-stt.ts index 0e21383d..be36b84b 100644 --- a/crates/workshop-server/ui/src/ui/realtime-stt.ts +++ b/crates/workshop-server/ui/src/ui/realtime-stt.ts @@ -154,7 +154,16 @@ export function setupStt( void releaseCapture(); setRecording(false); } - const transcript = completion.transcript.trimEnd(); + const current = input.readRange(take.from, take.from + take.length); + const insertionWhitespace = current.match(/^\s+/)?.[0] ?? ""; + const authoritative = completion.transcript.trimEnd(); + const transcript = + take.original === "" && + authoritative !== "" && + insertionWhitespace !== "" && + !/^\s/.test(authoritative) + ? insertionWhitespace + authoritative + : authoritative; splice(take, transcript); removeTake(take); if (transcript === "") { diff --git a/crates/workshop-server/ui/test/agent-stt.mjs b/crates/workshop-server/ui/test/agent-stt.mjs index 82f5c005..abb3929f 100644 --- a/crates/workshop-server/ui/test/agent-stt.mjs +++ b/crates/workshop-server/ui/test/agent-stt.mjs @@ -4,7 +4,7 @@ // 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 { writeFile } from "node:fs/promises"; +import { readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -14,6 +14,27 @@ 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, + ); +} const bundle = await esbuild.build({ stdin: { @@ -281,23 +302,13 @@ async function harness() { ), ); const realtime = sockets.filter((socket) => socket.url.endsWith("/v1/realtime")).at(-1); - realtime.message({ - type: "session.created", - event_id: "created", - session: { id: "session", object: "realtime.transcription_session", type: "transcription", include: [], audio: { input: {} } }, - }); + realtime.message( + canonicalMessage("first_event_readiness", "server", "session.created"), + ); await waitFor(() => realtime.sent.some((event) => event.type === "session.update")); - realtime.message({ - type: "session.updated", - event_id: "updated", - session: { - id: "session", - object: "realtime.transcription_session", - type: "transcription", - include: ["item.input_audio_transcription.hypothesis"], - audio: { input: {} }, - }, - }); + 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 @@ -324,6 +335,96 @@ async function harness() { } 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 committed = canonicalMessage( + "immediate_commit_and_provisional_promotion", + "server", + "input_audio_buffer.committed", + ); + committed.item_id = "item_hypothesis"; + socket.message(committed); + socket.message( + canonicalMessage( + "hypothesis_negotiation", + "server", + "conversation.item.input_audio_transcription.hypothesis", + ), + ); + check("the first canonical hypothesis replaces the take", input.getText() === "Hello"); + socket.message( + canonicalMessage( + "hypothesis_negotiation", + "server", + "conversation.item.input_audio_transcription.hypothesis", + 1, + ), + ); + check("the revised canonical hypothesis replaces rather than appends", input.getText() === "Hello!"); + 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.", + ); + + 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(); + } + // --- The pinned wait gates the mic; a dying wait discards the take ------- { @@ -733,16 +834,17 @@ await assertNoLeaks(lifecycle, async () => { () => socket.sent.filter((event) => event.type === "input_audio_buffer.commit").length === 1, ); - socket.message({ - type: "input_audio_buffer.committed", - event_id: "overlap_commit_a", - item_id: "overlap_a", - previous_item_id: null, - }); + socket.message( + canonicalMessage( + "overlapping_items_reverse_completion", + "server", + "input_audio_buffer.committed", + ), + ); socket.message({ type: "conversation.item.input_audio_transcription.hypothesis", event_id: "overlap_hypothesis_a", - item_id: "overlap_a", + item_id: "item_overlap_a", content_index: 0, revision: 1, transcript: "first", @@ -759,16 +861,18 @@ await assertNoLeaks(lifecycle, async () => { () => socket.sent.filter((event) => event.type === "input_audio_buffer.commit").length === 2, ); - socket.message({ - type: "input_audio_buffer.committed", - event_id: "overlap_commit_b", - item_id: "overlap_b", - previous_item_id: "overlap_a", - }); + socket.message( + canonicalMessage( + "overlapping_items_reverse_completion", + "server", + "input_audio_buffer.committed", + 1, + ), + ); socket.message({ type: "conversation.item.input_audio_transcription.hypothesis", event_id: "overlap_hypothesis_b", - item_id: "overlap_b", + item_id: "item_overlap_b", content_index: 0, revision: 1, transcript: " second", @@ -783,22 +887,17 @@ await assertNoLeaks(lifecycle, async () => { input.getText() === "base first second" && !editable(), ); - for (const [itemId, transcript] of [ - ["overlap_b", " SECOND"], - ["overlap_a", "FIRST LONG"], - ]) { - socket.message({ - type: "conversation.item.input_audio_transcription.completed", - event_id: `overlap_done_${itemId}`, - item_id: itemId, - content_index: 0, - transcript, - usage: { type: "duration", seconds: 0.1 }, - }); + 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 LONG SECOND" && editable(), + input.getText() === "base first second" && editable(), ); dispose(); } diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index c38ba572..cab63b4b 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -671,7 +671,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. From 49441166f580a3d6339532a3c1fd3c1205e484cd Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 21:12:17 -0700 Subject: [PATCH 40/86] Require a model before built-in chat turns Block chat submission until Workshop has a model selection, and surface a failed model turn if the selected binding disappears before dispatch. Preserve the pending draft so a later selection can resume without sending an unbound Gateway request. Keep the temporary legacy boundary aligned with the revised execution order. - `migration_targets."stt.rs"` and `DEPENDENCY_POLICIES` retain the temporary legacy socket boundary until its revised removal point. - `AgentSessionView` stores its status and model collaborators, reacts to selection changes, and gates click and keyboard submission while the selection is empty. - `dispatch_chat` emits `MODEL_TURN_FAILED` for absent named or default bindings before it returns the program error. - `deliver_input_response_before_completion`, `reconcile_catalog_for_test`, and `deliver_input_after_acceptance_for_test` expose fixture seams that remove a selection after input acceptance but before Lua resumes. - `a_missing_chat_binding_reports_one_failed_turn_before_lua_pcall_resumes`, `gate_binding_loss_surfaces_one_error_and_recovers_after_selection`, and the browser checks pin one visible failure, no unbound Gateway request, retained text, and recovery after selection. Design: new shared-parameter-cluster @ crates/workshop-server/src/input.rs::deliver_input_response_before_completion deps: InputResponse,WaitRegistry,dyn Observer,impl FnOnce(),str,str Design: new feature-flag @ crates/workshop-server/src/menu.rs::MenuBus::reconcile_catalog_for_test Design: new surface-growth @ crates/workshop-server/src/menu.rs::MenuBus::reconcile_catalog_for_test boundary: pub Design: new feature-flag @ crates/workshop-server/src/session_agents.rs::AgentSessions::deliver_input_after_acceptance_for_test Design: new surface-growth @ crates/workshop-server/src/session_agents.rs::AgentSessions::deliver_input_after_acceptance_for_test boundary: pub Design: new oversized-unit @ crates/workshop-server/tests/it/chat_gate.rs::gate_binding_loss_surfaces_one_error_and_recovers_after_selection Design: extends constructor-injection @ crates/workshop-server/ui/src/ui/agent-session-view.ts::AgentSessionView Design: new event-hook @ crates/workshop-server/ui/src/ui/agent-session-view.ts::AgentSessionView Design: new surface-growth @ crates/workshop-server/ui/src/ui/agent-session-view.ts::AgentSessionView boundary: pub Pending: N49 - compounds Pending: N50 - compounds Pending: N57 - compounds Pending: N58 - compounds Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- crates/gateway-stt/module-ceilings.toml | 2 +- crates/gateway-stt/tests/it/architecture.rs | 4 +- crates/promptforge-agent/src/agent.rs | 25 ++-- crates/promptforge-agent/src/tests.rs | 59 ++++++++- crates/workshop-server/src/input.rs | 21 ++++ crates/workshop-server/src/menu.rs | 7 ++ crates/workshop-server/src/session_agents.rs | 24 ++++ crates/workshop-server/tests/it/chat_gate.rs | 117 +++++++++++++++++- .../ui/src/ui/agent-session-view.ts | 22 +++- .../ui/test/agent-session-view.mjs | 70 +++++++++++ vibe/2026-09-05-2-generic-realtime-stt.md | 48 ++++--- vibe/archdoc-next.md | 8 +- 12 files changed, 366 insertions(+), 41 deletions(-) diff --git a/crates/gateway-stt/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index 6076481a..9ef0301f 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -5,7 +5,7 @@ public_root_budget = 6 [migration_targets."stt.rs"] -target_step = "Step 32" +target_step = "Step 33" destination = "removal after the Realtime route and Workshop relay replace the legacy socket" [modules] diff --git a/crates/gateway-stt/tests/it/architecture.rs b/crates/gateway-stt/tests/it/architecture.rs index 848a798c..83bfadad 100644 --- a/crates/gateway-stt/tests/it/architecture.rs +++ b/crates/gateway-stt/tests/it/architecture.rs @@ -83,7 +83,7 @@ const DEPENDENCY_POLICIES: [DependencyPolicy; 7] = [ ], temporary_edges: &[TemporaryEdge { dependency: "workshop-server", - removal_step: "Step 32", + removal_step: "Step 33", }], }, DependencyPolicy { @@ -132,7 +132,7 @@ const MIGRATION_POLICIES: [MigrationPolicy; 4] = [ crate_name: "gateway-stt", targets: &[MigrationPolicyTarget { module: "stt.rs", - target_step: "Step 32", + target_step: "Step 33", destination: "removal after the Realtime route and Workshop relay replace the legacy socket", }], }, 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/workshop-server/src/input.rs b/crates/workshop-server/src/input.rs index 93c9d658..0cb08e35 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) } diff --git a/crates/workshop-server/src/menu.rs b/crates/workshop-server/src/menu.rs index bd84c4f9..4f17aef0 100644 --- a/crates/workshop-server/src/menu.rs +++ b/crates/workshop-server/src/menu.rs @@ -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> { diff --git a/crates/workshop-server/src/session_agents.rs b/crates/workshop-server/src/session_agents.rs index fa32fe9a..2daaa50a 100644 --- a/crates/workshop-server/src/session_agents.rs +++ b/crates/workshop-server/src/session_agents.rs @@ -50,8 +50,12 @@ use tokio::sync::broadcast; use crate::backoff::ReconnectBackoff; use crate::catalog::CatalogBus; use crate::input::{UserInputTool, WaitRegistry}; +#[cfg(feature = "test-fixtures")] +use crate::input::{WaitError, deliver_input_response_before_completion}; use crate::menu::MenuBus; use crate::observer::WorkshopObserver; +#[cfg(feature = "test-fixtures")] +use crate::protocol::InputResponse; use crate::protocol::{Activity, AgentDeltaKind, InputFrame}; use crate::push::Push; use crate::workspace::Workspace; @@ -285,6 +289,26 @@ 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(deliver_input_response_before_completion( + session.log.as_ref(), + &session.waits, + &session.id, + &session.agent, + 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>> { diff --git a/crates/workshop-server/tests/it/chat_gate.rs b/crates/workshop-server/tests/it/chat_gate.rs index 87217ece..ea3472d9 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. @@ -134,6 +134,13 @@ 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( @@ -167,10 +174,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"); @@ -642,3 +651,101 @@ async fn gate_model_failure_surfaces_an_error_and_the_next_input_works() { 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/ui/src/ui/agent-session-view.ts b/crates/workshop-server/ui/src/ui/agent-session-view.ts index 904e3b65..b7d9c589 100644 --- a/crates/workshop-server/ui/src/ui/agent-session-view.ts +++ b/crates/workshop-server/ui/src/ui/agent-session-view.ts @@ -173,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; @@ -192,8 +193,8 @@ export class AgentSessionView extends Disposable { constructor( private readonly service: AgentSessionService, - status: SttStatus, - modelService?: ModelService, + private readonly status: SttStatus, + private readonly modelService?: ModelService, speechCapture?: SpeechCaptureService, ) { super(); @@ -252,6 +253,9 @@ 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 @@ -316,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 === ""), + ); } /** @@ -331,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/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/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index cab63b4b..73cd0d73 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -41,6 +41,9 @@ todos: - 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 isProject: false --- @@ -65,7 +68,7 @@ isProject: false - 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 34 are the sole logging exception. + - 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 35 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. @@ -246,6 +249,8 @@ isProject: false - 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. - 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. @@ -358,7 +363,7 @@ isProject: false 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 31, adds only `workshop-server -> shared-loopback`. Final Phase C, Step 32 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. +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 32, adds only `workshop-server -> shared-loopback`. Final Phase C, Step 33 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] @@ -681,20 +686,33 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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: Pass installed Windows microphone acceptance +### 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: Pass installed Windows microphone acceptance - 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` steps `Build and stage the gateway sidecar`, `Build the app`, and `Install and check (Windows)`, then record installed-package microphone revision, completion, second take, clear, cancellation, and recoverable permission or device failure with binary hashes and timestamps. +- 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`: `if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY)) { throw 'TAURI_SIGNING_PRIVATE_KEY is required by the release workflow' }; if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD)) { throw 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD is required by the release workflow' }; $env:RUSTUP_TOOLCHAIN='stable'; cargo tauri build --bundles nsis` + - `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 30; the operator must exercise the installed application and record passing evidence, which alone gates legacy removal. +- Consumes and gates: consumes Steps 30 and 31; the operator must exercise the installed application and record passing evidence, which alone gates legacy removal. -### Step 32: Remove legacy seams and tests +### Step 33: Remove legacy seams and tests - 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. @@ -708,7 +726,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 33: Finalize architecture and documentation +### Step 34: Finalize architecture and documentation - 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. @@ -718,9 +736,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 32 final topology; final verification starts only with zero temporary exceptions. +- Consumes and gates: consumes Step 33 final topology; final verification starts only with zero temporary exceptions. -### Step 34: Bookend Gateway serving logs +### Step 35: Bookend Gateway serving logs - 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. @@ -728,9 +746,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 33 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 35's full release verification must pass after this change. +- Consumes and gates: this operator-requested observability correction is independent of STT and follows Step 34 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 36's full release verification must pass after this change. -### Step 35: Run every release gate and repeat acceptance +### Step 36: Run every release gate and repeat acceptance - Artifacts: append command results, hashes, ratchet counts, native equivalence, generated-doc cleanliness, and repeated installed-microphone evidence to `design/generic-realtime-stt-acceptance.md`; change no implementation. - Scope: run every exit criterion independently under PowerShell 5.1 and stop on any failure. @@ -761,9 +779,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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`: `if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY)) { throw 'TAURI_SIGNING_PRIVATE_KEY is required by the release workflow' }; if ([string]::IsNullOrWhiteSpace($env:TAURI_SIGNING_PRIVATE_KEY_PASSWORD)) { throw 'TAURI_SIGNING_PRIVATE_KEY_PASSWORD is required by the release workflow' }; $env:RUSTUP_TOOLCHAIN='stable'; cargo tauri build --bundles nsis` + - `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 34, then repeats the Step 31 installed-package microphone scenarios. Completion requires every command, ratchet, generated-doc check, and physical scenario to pass with no open finding. +- Consumes and gates: consumes Step 35, then repeats the Step 32 installed-package microphone scenarios. 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 34's exact two records, or completed Workshop baseline-ratchet work. VAD tuning, alignment, 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 +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 35's exact two records, or completed Workshop baseline-ratchet work. VAD tuning, alignment, 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/archdoc-next.md b/vibe/archdoc-next.md index 14fa9a42..bf7d3d63 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -174,13 +174,13 @@ N45 | observation | shared-parameter-cluster @ crates/gateway/src/lib.rs::reques 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 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 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 -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 +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 +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 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 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 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 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 -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 +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 +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 From 94357c391e9ddf96ee20e52003aa659f11c61e4b Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Sun, 6 Sep 2026 23:46:40 -0700 Subject: [PATCH 41/86] Converge Workshop startup state Retry incomplete profile and catalog refreshes, reconnect failed transcription sockets with capped backoff, and release imported progress by operation while the shared stream stays open. This lets simultaneous Gateway and Workshop startup reach a selectable model and clears completed status without another microphone action. - `EventState::OperationFinished` adds an operation-level terminal wire event before source detachment. `RemoteOperation` ignores that marker in `apply`, and dropping the import publishes local completion. - `remotes` keys imported progress by `OperationId`, so one terminal event removes only its matching `RemoteOperation`. - `profiles_ready`, `catalog_ready`, and `selection_restored` keep healthy retries independent and restore the model selection once after both sources converge. - `scheduleReconnect` doubles `reconnectDelayMs` from `RECONNECT_INITIAL_MS` to `RECONNECT_MAX_MS`; `dispose` cancels the active `reconnectTimer`. - `startup_convergence` tests continuously healthy startup with delayed catalog and profile readiness. `stt-stream.mjs` tests retry timing, capped delay, readiness reset, and disposal cancellation. Design: new surface-growth @ crates/shared-progress/src/event.rs::EventState boundary: wire Design: new temporal-coupling @ crates/shared-progress/src/tree.rs::TreeState::finish_operation Design: extends dispatch-on-tag @ crates/shared-progress/src/remote.rs::RemoteOperation::apply Design: new registry @ crates/workshop-server/src/gateway_progress.rs::run deps: Arc,Duration,GatewayHealth,oneshot::Receiver<()>,str,str Design: new oversized-unit @ crates/workshop-server/src/heartbeat.rs::run deps: Duration,GatewayClient,GatewayHealth,Push,ReconnectBackoff,oneshot::Receiver<()> Design: extends surface-growth @ crates/workshop-server/ui/src/services/realtime-transcription.ts boundary: pub Design: extends event-hook @ crates/workshop-server/ui/src/services/realtime-transcription.ts::RealtimeTranscriptionService Design: extends temporal-coupling @ crates/workshop-server/ui/src/services/realtime-transcription.ts::RealtimeTranscriptionService Design: extends oversized-unit @ crates/workshop-server/ui/src/services/realtime-transcription.ts::RealtimeTranscriptionService Design: extends dispatch-on-tag @ crates/workshop-server/ui/src/services/realtime-transcription.ts::handleMessage Pending: N53 - compounds Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- crates/gateway-stt/module-ceilings.toml | 2 +- crates/gateway-stt/tests/it/architecture.rs | 4 +- crates/gateway/src/lib.rs | 35 ++- crates/shared-progress/src/event.rs | 32 ++- crates/shared-progress/src/hub.rs | 5 + crates/shared-progress/src/remote.rs | 5 + crates/shared-progress/src/tree.rs | 13 ++ crates/workshop-server/module-ceilings.toml | 5 + .../workshop-server/src/gateway_progress.rs | 34 ++- .../src/gateway_progress/tests/lifecycle.rs | 76 +++++++ crates/workshop-server/src/heartbeat.rs | 167 +++++++------- .../heartbeat/tests/startup_convergence.rs | 213 ++++++++++++++++++ .../ui/src/services/realtime-transcription.ts | 40 +++- crates/workshop-server/ui/test/stt-stream.mjs | 77 +++++++ vibe/2026-09-05-2-generic-realtime-stt.md | 56 ++++- vibe/archdoc-next.md | 6 +- 16 files changed, 642 insertions(+), 128 deletions(-) create mode 100644 crates/workshop-server/src/gateway_progress/tests/lifecycle.rs create mode 100644 crates/workshop-server/src/heartbeat/tests/startup_convergence.rs diff --git a/crates/gateway-stt/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index 9ef0301f..332a3f77 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -5,7 +5,7 @@ public_root_budget = 6 [migration_targets."stt.rs"] -target_step = "Step 33" +target_step = "Step 35" destination = "removal after the Realtime route and Workshop relay replace the legacy socket" [modules] diff --git a/crates/gateway-stt/tests/it/architecture.rs b/crates/gateway-stt/tests/it/architecture.rs index 83bfadad..63986f2e 100644 --- a/crates/gateway-stt/tests/it/architecture.rs +++ b/crates/gateway-stt/tests/it/architecture.rs @@ -83,7 +83,7 @@ const DEPENDENCY_POLICIES: [DependencyPolicy; 7] = [ ], temporary_edges: &[TemporaryEdge { dependency: "workshop-server", - removal_step: "Step 33", + removal_step: "Step 35", }], }, DependencyPolicy { @@ -132,7 +132,7 @@ const MIGRATION_POLICIES: [MigrationPolicy; 4] = [ crate_name: "gateway-stt", targets: &[MigrationPolicyTarget { module: "stt.rs", - target_step: "Step 33", + target_step: "Step 35", destination: "removal after the Realtime route and Workshop relay replace the legacy socket", }], }, diff --git a/crates/gateway/src/lib.rs b/crates/gateway/src/lib.rs index aea13085..58e8cfc3 100644 --- a/crates/gateway/src/lib.rs +++ b/crates/gateway/src/lib.rs @@ -1208,7 +1208,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 @@ -4260,6 +4261,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()); @@ -4308,20 +4331,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 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/workshop-server/module-ceilings.toml b/crates/workshop-server/module-ceilings.toml index cb4ae737..bbf595b1 100644 --- a/crates/workshop-server/module-ceilings.toml +++ b/crates/workshop-server/module-ceilings.toml @@ -71,10 +71,15 @@ # 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 # 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 +# Simultaneous-startup convergence fixtures split from the heartbeat loop. +"heartbeat/tests/startup_convergence.rs" = 214 # 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 diff --git a/crates/workshop-server/src/gateway_progress.rs b/crates/workshop-server/src/gateway_progress.rs index f1063a27..7d52de90 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,7 +24,7 @@ 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::heartbeat::GatewayHealth; @@ -95,9 +97,11 @@ 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, @@ -136,14 +140,24 @@ async fn run( } }, }; - let remote = RemoteOperation::attach(hub); + let mut remotes: HashMap = HashMap::new(); tokio::pin!(stream); loop { tokio::select! { _ = &mut *stop => return, _ = reachable.changed() => break, 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,7 +167,7 @@ async fn run( } } } - drop(remote); + drop(remotes); if *reachable.borrow_and_update() { tokio::select! { _ = &mut *stop => return, @@ -463,4 +477,6 @@ mod tests { ); subscriber.shutdown().await; } + + mod lifecycle; } 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..dc351bd3 --- /dev/null +++ b/crates/workshop-server/src/gateway_progress/tests/lifecycle.rs @@ -0,0 +1,76 @@ +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( + base_url, + String::new(), + 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/heartbeat.rs b/crates/workshop-server/src/heartbeat.rs index 6e44a2ad..0495203b 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 @@ -154,10 +155,12 @@ 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, @@ -194,6 +197,9 @@ async fn run( stop: &mut oneshot::Receiver<()>, ) { let mut last: Option = None; + let mut profiles_ready = false; + let mut catalog_ready = false; + let mut selection_restored = false; loop { // The first probe runs immediately; every later one waits here. if let Some(reachable) = last { @@ -219,44 +225,64 @@ async fn run( reachable = client.health() => reachable, }; health.publish(reachable); - if last == Some(reachable) { + let transitioned = last != Some(reachable); + last = Some(reachable); + if transitioned { + // The menu recomputes chat_ready from reachability, so the + // verdict feeds it before any slower refresh work below. + push.menu().set_gateway_reachable(reachable); + if reachable { + push.push_status_update( + CONNECTED_LABEL, + "the gateway answers its health probe", + Activity::General, + ); + } else { + push.push_status_update( + UNREACHABLE_LABEL, + UNREACHABLE_DESCRIPTION, + Activity::General, + ); + } + } + if !reachable { + profiles_ready = false; + catalog_ready = false; + selection_restored = false; continue; } - 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 !profiles_ready || !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)); + match (profiles_ready, catalog_ready) { + (false, false) => { + (profiles_ready, catalog_ready) = + tokio::join!(refresh_profiles(client, push), refresh_catalog(client, push)); + } + (false, true) => profiles_ready = refresh_profiles(client, push).await, + (true, false) => catalog_ready = refresh_catalog(client, push).await, + (true, true) => {} + } } => {} } + } + if profiles_ready && catalog_ready && !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, - ); + selection_restored = true; } } } @@ -268,30 +294,37 @@ async fn run( /// 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) { +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; + return false; } }; if !response.status.is_success() { tracing::warn!(status = %response.status, "catalog refresh was declined"); - return; + 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; + 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; + return false; }; + let selectable = models.iter().any(|model| { + model + .get("id") + .and_then(serde_json::Value::as_str) + .is_some_and(|id| !id.is_empty()) + }); push.push_models_catalog(models.clone()); + selectable } /// The decoded body of `GET /admin/profiles`. @@ -318,10 +351,17 @@ struct ProfileStatus { /// 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) { +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 from `GET /admin/profiles`, or `None` @@ -377,7 +417,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; @@ -759,57 +799,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)); @@ -974,4 +963,6 @@ mod tests { .await .expect("shutdown does not wait out the interval"); } + + mod startup_convergence; } 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..540958b1 --- /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 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 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/ui/src/services/realtime-transcription.ts b/crates/workshop-server/ui/src/services/realtime-transcription.ts index 267b1553..313d4247 100644 --- a/crates/workshop-server/ui/src/services/realtime-transcription.ts +++ b/crates/workshop-server/ui/src/services/realtime-transcription.ts @@ -2,6 +2,8 @@ import { Emitter, type Event as ServiceEvent } from "../base/event"; import { Disposable } from "../base/lifecycle"; 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"; @@ -108,6 +110,8 @@ export class RealtimeTranscriptionService extends Disposable { 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; @@ -145,6 +149,7 @@ export class RealtimeTranscriptionService extends Disposable { } catch { this.setState("unavailable"); this.reportError("connection_failed"); + this.scheduleReconnect(); return; } this.socket = socket; @@ -155,7 +160,12 @@ export class RealtimeTranscriptionService extends Disposable { }; 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 => { @@ -163,11 +173,11 @@ export class RealtimeTranscriptionService extends Disposable { return; } this.socket = null; - this.negotiatedHypotheses = false; - this.deltas.clear(); + this.resetConnectionState(); if (!this.disposed) { this.setState("unavailable"); this.reportError("connection_closed"); + this.scheduleReconnect(); } }; if (socket.addEventListener !== undefined) { @@ -204,6 +214,10 @@ export class RealtimeTranscriptionService extends Disposable { return; } this.disposed = true; + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } const socket = this.socket; this.socket = null; socket?.close(); @@ -259,6 +273,11 @@ export class RealtimeTranscriptionService extends Disposable { Array.isArray(include) && include.length === 1 && include[0] === HYPOTHESIS_INCLUDE; + this.reconnectDelayMs = RECONNECT_INITIAL_MS; + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } this.setState("ready"); return; } @@ -357,6 +376,23 @@ export class RealtimeTranscriptionService extends Disposable { this.stateEmitter.fire(state); } + private resetConnectionState(): void { + this.negotiatedHypotheses = false; + this.deltas.clear(); + } + + 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", diff --git a/crates/workshop-server/ui/test/stt-stream.mjs b/crates/workshop-server/ui/test/stt-stream.mjs index 4b9cddc4..0c1ca8a3 100644 --- a/crates/workshop-server/ui/test/stt-stream.mjs +++ b/crates/workshop-server/ui/test/stt-stream.mjs @@ -2,6 +2,7 @@ // 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 } from "node:url"; import * as esbuild from "esbuild"; @@ -143,3 +144,79 @@ await assertNoLeaks(lifecycle, async () => { service.dispose(); assert.equal(sockets[0].readyState, ScriptedSocket.CLOSED); }); + +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/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 73cd0d73..1a6c66f9 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -44,6 +44,12 @@ todos: - 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 --- @@ -68,7 +74,7 @@ isProject: false - 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 35 are the sole logging exception. + - 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 37 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. @@ -251,6 +257,8 @@ isProject: false - 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. @@ -363,7 +371,7 @@ isProject: false 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 32, adds only `workshop-server -> shared-loopback`. Final Phase C, Step 33 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. +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 34, adds only `workshop-server -> shared-loopback`. Final Phase C, Step 35 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] @@ -699,7 +707,31 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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: Pass installed Windows microphone acceptance +### 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 + +- 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: Pass installed Windows microphone acceptance - 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. @@ -710,9 +742,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 and 31; the operator must exercise the installed application and record passing evidence, which alone gates legacy removal. +- Consumes and gates: consumes Steps 30 through 33; the operator must exercise the installed application and record passing evidence, which alone gates legacy removal. -### Step 33: Remove legacy seams and tests +### Step 35: Remove legacy seams and tests - 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. @@ -726,7 +758,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 34: Finalize architecture and documentation +### Step 36: Finalize architecture and documentation - 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. @@ -736,9 +768,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 33 final topology; final verification starts only with zero temporary exceptions. +- Consumes and gates: consumes Step 35 final topology; final verification starts only with zero temporary exceptions. -### Step 35: Bookend Gateway serving logs +### Step 37: Bookend Gateway serving logs - 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. @@ -746,9 +778,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 34 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 36's full release verification must pass after this change. +- Consumes and gates: this operator-requested observability correction is independent of STT and follows Step 36 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 38's full release verification must pass after this change. -### Step 36: Run every release gate and repeat acceptance +### Step 38: Run every release gate and repeat acceptance - Artifacts: append command results, hashes, ratchet counts, native equivalence, generated-doc cleanliness, and repeated installed-microphone evidence to `design/generic-realtime-stt-acceptance.md`; change no implementation. - Scope: run every exit criterion independently under PowerShell 5.1 and stop on any failure. @@ -782,6 +814,6 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 35, then repeats the Step 32 installed-package microphone scenarios. Completion requires every command, ratchet, generated-doc check, and physical scenario to pass with no open finding. +- Consumes and gates: consumes Step 37, then repeats the Step 34 installed-package microphone scenarios. 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 35's exact two records, or completed Workshop baseline-ratchet work. VAD tuning, alignment, 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 +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 37's exact two records, or completed Workshop baseline-ratchet work. VAD tuning, alignment, 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/archdoc-next.md b/vibe/archdoc-next.md index bf7d3d63..1c1cde1b 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -178,9 +178,9 @@ N49 | observation | oversized-unit @ crates/workshop-server/tests/it/realtime_re 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 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 -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 -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 +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 +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 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 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 From aeec7b48fad441f42e5b66ec09274f50455180eb Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 00:15:44 -0700 Subject: [PATCH 42/86] Bind live hypotheses before commit acknowledgment Bind a lone unknown hypothesis to the active uncommitted take so interim revisions appear while recording. Confirm matching acknowledgments without changing text, retire mismatches with rollback, and preserve tombstone and overlap ordering. - `retiredItems` keeps rejected and removed item identifiers in setup-local state so late hypotheses cannot bind to another take. - `applySnapshot` binds only when one unbound take exists and rejects retired or tombstone-ambiguous items. `realtime.onCommitted` confirms matching identities, consumes FIFO tombstones, and rolls back mismatches. - `agent-stt.mjs` covers repeated precommit revisions, unknown items, mismatched acknowledgments, FIFO recovery, tombstones, and overlapping takes. Design: new shared-mutable-state @ crates/workshop-server/ui/src/ui/realtime-stt.ts::setupStt::retiredItems Design: extends oversized-unit @ crates/workshop-server/ui/src/ui/realtime-stt.ts::setupStt deps: RealtimeTranscriptionService,SpeechCaptureService,SttBlocker,SttElements,SttStatus boundary: pub Violates: A96 - bounded third-party model content in setupStt is not determinable from diff Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- .../workshop-server/ui/src/ui/realtime-stt.ts | 55 +++- crates/workshop-server/ui/test/agent-stt.mjs | 255 +++++++++++++----- vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- vibe/archdoc-next.md | 1 + 4 files changed, 231 insertions(+), 82 deletions(-) diff --git a/crates/workshop-server/ui/src/ui/realtime-stt.ts b/crates/workshop-server/ui/src/ui/realtime-stt.ts index be36b84b..ec27c29d 100644 --- a/crates/workshop-server/ui/src/ui/realtime-stt.ts +++ b/crates/workshop-server/ui/src/ui/realtime-stt.ts @@ -54,6 +54,7 @@ export function setupStt( const awaitingCommit: Array = []; const byItem = new Map(); const byClientEvent = new Map(); + const retiredItems = new Set(); let active: Take | null = null; let stopping = false; let pendingCaptureStop: Promise | null = null; @@ -110,6 +111,7 @@ export function setupStt( } if (take.itemId !== null) { byItem.delete(take.itemId); + retiredItems.add(take.itemId); } if (active === take) { active = null; @@ -138,10 +140,20 @@ export function setupStt( } function applySnapshot(snapshot: RealtimeTranscriptSnapshot): void { - const take = takeFor(snapshot.itemId); - if (take !== null) { - splice(take, snapshot.text); + let take = takeFor(snapshot.itemId); + if (take === null) { + if (retiredItems.has(snapshot.itemId) || awaitingCommit.includes(null)) { + return; + } + const unbound = takes.filter((candidate) => candidate.itemId === null); + if (unbound.length !== 1) { + return; + } + take = unbound[0]; + take.itemId = snapshot.itemId; + byItem.set(snapshot.itemId, take); } + splice(take, snapshot.text); } function applyCompletion(completion: RealtimeTranscriptCompletion): void { @@ -176,20 +188,39 @@ export function setupStt( store.add( realtime.onCommitted((itemId) => { - const known = byItem.get(itemId); - if (known !== undefined) { - const index = awaitingCommit.indexOf(known); - if (index >= 0) { - awaitingCommit.splice(index, 1); + if (awaitingCommit.length === 0) { + if (byItem.has(itemId)) { + return; + } + if (active !== null && active.itemId === null) { + active.itemId = itemId; + byItem.set(itemId, active); + return; } + retiredItems.add(itemId); + status.showLocal("Dictation is temporarily unavailable. Try again.", "error"); return; } - const take = awaitingCommit.length > 0 ? awaitingCommit.shift() : active; - if (take === undefined || take === null) { + const take = awaitingCommit[0]; + if (take === null) { + awaitingCommit.shift(); + retiredItems.add(itemId); return; } - take.itemId = itemId; - byItem.set(itemId, take); + if (take.itemId === null) { + awaitingCommit.shift(); + take.itemId = itemId; + byItem.set(itemId, take); + return; + } + if (take.itemId === itemId) { + awaitingCommit.shift(); + return; + } + awaitingCommit.shift(); + retiredItems.add(itemId); + rollback(take); + status.showLocal("Dictation is temporarily unavailable. Try again.", "error"); }), ); store.add(realtime.onSnapshot(applySnapshot)); diff --git a/crates/workshop-server/ui/test/agent-stt.mjs b/crates/workshop-server/ui/test/agent-stt.mjs index abb3929f..a9d92af9 100644 --- a/crates/workshop-server/ui/test/agent-stt.mjs +++ b/crates/workshop-server/ui/test/agent-stt.mjs @@ -174,43 +174,45 @@ class FakeWebSocket { } // Test-side control, not part of the WebSocket surface. message(frame) { - if (frame.type === "interim" || frame.type === "final") { + if (frame.type === "interim") { 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 = - frame.type === "interim" - ? { - type: "conversation.item.input_audio_transcription.hypothesis", - event_id: `hypothesis_${nextItem}`, - item_id: this.itemId, - content_index: 0, - revision: 1, - transcript: [frame.committed, frame.tentative].filter(Boolean).join( - frame.committed && frame.tentative && !/\s$/.test(frame.committed) ? " " : "", - ), - finalized: frame.committed ?? "", - agreed: "", - tentative: frame.tentative ?? "", - audio_start_ms: 0, - audio_end_ms: 100, - } - : { - 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 }, - }; + frame = { + type: "conversation.item.input_audio_transcription.hypothesis", + event_id: `hypothesis_${nextItem}`, + item_id: this.itemId, + content_index: 0, + revision: 1, + transcript: [frame.committed, frame.tentative].filter(Boolean).join( + frame.committed && frame.tentative && !/\s$/.test(frame.committed) ? " " : "", + ), + finalized: frame.committed ?? "", + agreed: "", + tentative: frame.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") { @@ -366,21 +368,16 @@ await assertNoLeaks(lifecycle, async () => { "canonical fixture emits the shared valid-sized audio append", append?.audio === canonicalAppend.audio, ); - const committed = canonicalMessage( - "immediate_commit_and_provisional_promotion", + const firstHypothesis = canonicalMessage( + "hypothesis_negotiation", "server", - "input_audio_buffer.committed", + "conversation.item.input_audio_transcription.hypothesis", ); - committed.item_id = "item_hypothesis"; - socket.message(committed); - socket.message( - 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", ); - check("the first canonical hypothesis replaces the take", input.getText() === "Hello"); socket.message( canonicalMessage( "hypothesis_negotiation", @@ -389,7 +386,34 @@ await assertNoLeaks(lifecycle, async () => { 1, ), ); - check("the revised canonical hypothesis replaces rather than appends", input.getText() === "Hello!"); + 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", @@ -403,6 +427,16 @@ await assertNoLeaks(lifecycle, async () => { 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); @@ -425,6 +459,89 @@ await assertNoLeaks(lifecycle, async () => { 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 ------- { @@ -796,12 +913,6 @@ await assertNoLeaks(lifecycle, async () => { () => socket.sent.filter((event) => event.type === "input_audio_buffer.commit").length === 2, ); - socket.message({ - type: "input_audio_buffer.committed", - event_id: "current_commit", - item_id: "current_item", - previous_item_id: "discarded_item", - }); socket.message({ type: "conversation.item.input_audio_transcription.hypothesis", event_id: "current_hypothesis", @@ -815,8 +926,14 @@ await assertNoLeaks(lifecycle, async () => { 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( - "the acknowledgment after a tombstone binds the current take", + "a precommit hypothesis after a tombstone binds the current take", input.getText() === "right take", ); dispose(); @@ -834,13 +951,6 @@ await assertNoLeaks(lifecycle, async () => { () => socket.sent.filter((event) => event.type === "input_audio_buffer.commit").length === 1, ); - socket.message( - canonicalMessage( - "overlapping_items_reverse_completion", - "server", - "input_audio_buffer.committed", - ), - ); socket.message({ type: "conversation.item.input_audio_transcription.hypothesis", event_id: "overlap_hypothesis_a", @@ -854,21 +964,20 @@ await assertNoLeaks(lifecycle, async () => { 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, - ); socket.message( canonicalMessage( "overlapping_items_reverse_completion", "server", "input_audio_buffer.committed", - 1, ), ); + + 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", @@ -882,6 +991,14 @@ await assertNoLeaks(lifecycle, async () => { audio_start_ms: 100, audio_end_ms: 200, }); + socket.message( + canonicalMessage( + "overlapping_items_reverse_completion", + "server", + "input_audio_buffer.committed", + 1, + ), + ); check( "overlapping hypotheses occupy isolated replacement regions", input.getText() === "base first second" && !editable(), diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 1a6c66f9..ee683181 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -720,7 +720,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 1c1cde1b..a13ae41b 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -184,3 +184,4 @@ N55 | observation | dispatch-on-tag @ crates/workshop-server/ui/src/services/rea 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 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 From fb4e0bfedfaff592a3627dc5628c5aee168dc62a Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 02:03:42 -0700 Subject: [PATCH 43/86] Converge chat sessions with live catalogs Make the chat-only catalog the shared boundary for model menus and agent runs. Wait for a usable model before each run, then retire stale generations only after accepted input settles so profile switches preserve history without replaying or dropping turns. - `ChatCatalogBus` retains filtered snapshots and advances its generation only when chat-capable models change. `is_chat_capable` now governs publication, readiness, selection, and agent model construction. - `spawn` freezes one catalog per run, waits through empty startup, and relaunches on usable replacement over the retained event log. `RunLifecycle` distinguishes operator cancellation from catalog retirement and protects accepted input until a terminal event. - `gate_delayed_catalog_starts_chat_only_after_a_chat_model_arrives`, `gate_profile_switch_relaunches_chat_with_history_and_the_new_catalog`, and `gate_catalog_replacement_during_acceptance_settles_the_turn_exactly_once` pin startup, profile-switch, and accepted-input races. Design: new shared-mutable-state @ crates/workshop-server/src/catalog/chat.rs::ChatCatalogBus Design: new pure-function @ crates/workshop-server/src/catalog/chat.rs::is_chat_capable deps: serde_json::Value Design: new shared-mutable-state @ crates/workshop-server/src/session_agents/lifecycle.rs::RunLifecycle Design: new oversized-unit @ crates/workshop-server/src/session_agents/supervisor.rs::spawn deps: AgentSession,AgentSessions,ModelClient,SessionHost Design: new oversized-unit @ crates/workshop-server/tests/it/chat_gate.rs::gate_catalog_replacement_during_acceptance_settles_the_turn_exactly_once Pending: N49 - compounds Pending: N50 - compounds Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- crates/workshop-server/module-ceilings.toml | 15 +- crates/workshop-server/src/catalog.rs | 65 ++-- crates/workshop-server/src/catalog/chat.rs | 83 +++++ crates/workshop-server/src/catalog/tests.rs | 38 +++ crates/workshop-server/src/heartbeat.rs | 10 +- .../heartbeat/tests/startup_convergence.rs | 2 +- crates/workshop-server/src/menu.rs | 33 +- crates/workshop-server/src/protocol.rs | 4 +- crates/workshop-server/src/push.rs | 8 +- crates/workshop-server/src/session_agents.rs | 196 ++++-------- .../src/session_agents/lifecycle.rs | 102 ++++++ .../src/session_agents/socket.rs | 10 +- .../src/session_agents/supervisor.rs | 199 ++++++++++++ crates/workshop-server/tests/it/chat_gate.rs | 295 +++++++++++++++++- vibe/2026-09-05-2-generic-realtime-stt.md | 45 ++- vibe/archdoc-next.md | 4 +- 16 files changed, 874 insertions(+), 235 deletions(-) create mode 100644 crates/workshop-server/src/catalog/chat.rs create mode 100644 crates/workshop-server/src/catalog/tests.rs create mode 100644 crates/workshop-server/src/session_agents/lifecycle.rs create mode 100644 crates/workshop-server/src/session_agents/supervisor.rs diff --git a/crates/workshop-server/module-ceilings.toml b/crates/workshop-server/module-ceilings.toml index bbf595b1..d5eb6e76 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. @@ -190,7 +196,12 @@ # 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 +# New module: cancellation provenance and accepted-turn settlement. +"session_agents/lifecycle.rs" = 102 +# New module: one agent session's run lifecycle across turn cancellation, +# delayed catalog readiness, and usable chat-catalog replacement. +"session_agents/supervisor.rs" = 170 # 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/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/heartbeat.rs b/crates/workshop-server/src/heartbeat.rs index 0495203b..d2fc8aa2 100644 --- a/crates/workshop-server/src/heartbeat.rs +++ b/crates/workshop-server/src/heartbeat.rs @@ -33,6 +33,7 @@ use std::time::Duration; use tokio::sync::{oneshot, watch}; use crate::backoff::ReconnectBackoff; +use crate::catalog::is_chat_capable; use crate::gateway::GatewayClient; use crate::protocol::{Activity, Severity, StatusBarUpdate}; use crate::push::Push; @@ -317,12 +318,7 @@ pub(crate) async fn refresh_catalog(client: &GatewayClient, push: &Push) -> bool tracing::warn!("catalog refresh carried no data array"); return false; }; - let selectable = models.iter().any(|model| { - model - .get("id") - .and_then(serde_json::Value::as_str) - .is_some_and(|id| !id.is_empty()) - }); + let selectable = models.iter().any(is_chat_capable); push.push_models_catalog(models.clone()); selectable } @@ -762,7 +758,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; } diff --git a/crates/workshop-server/src/heartbeat/tests/startup_convergence.rs b/crates/workshop-server/src/heartbeat/tests/startup_convergence.rs index 540958b1..f321f946 100644 --- a/crates/workshop-server/src/heartbeat/tests/startup_convergence.rs +++ b/crates/workshop-server/src/heartbeat/tests/startup_convergence.rs @@ -90,7 +90,7 @@ async fn the_initial_connect_pushes_the_catalog_and_readies_chat() { .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" ); let ready = snapshot_where(&menu, |snapshot| snapshot.chat_ready).await; assert_eq!( diff --git a/crates/workshop-server/src/menu.rs b/crates/workshop-server/src/menu.rs index 4f17aef0..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 @@ -341,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, @@ -391,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/session_agents.rs b/crates/workshop-server/src/session_agents.rs index 2daaa50a..167eea7c 100644 --- a/crates/workshop-server/src/session_agents.rs +++ b/crates/workshop-server/src/session_agents.rs @@ -25,7 +25,9 @@ //! 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; @@ -35,7 +37,6 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, 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}; @@ -43,23 +44,19 @@ use promptforge_model_client::client::{ GatewayClient as ModelClient, GatewayEndpoint, SecretString, 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::{Notify, broadcast}; use crate::backoff::ReconnectBackoff; -use crate::catalog::CatalogBus; -use crate::input::{UserInputTool, WaitRegistry}; -#[cfg(feature = "test-fixtures")] -use crate::input::{WaitError, deliver_input_response_before_completion}; +use crate::catalog::{CatalogBus, is_chat_capable}; +use crate::input::{WaitError, WaitRegistry, deliver_input_response_before_completion}; use crate::menu::MenuBus; use crate::observer::WorkshopObserver; -#[cfg(feature = "test-fixtures")] -use crate::protocol::InputResponse; -use crate::protocol::{Activity, AgentDeltaKind, InputFrame}; +use crate::protocol::{Activity, AgentDeltaKind, InputFrame, InputResponse}; use crate::push::Push; use crate::workspace::Workspace; +use self::lifecycle::{CancelOrigin, RunLifecycle}; + /// 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. @@ -236,6 +233,7 @@ impl AgentSessions { WorkshopObserver::new(Some(&log_path)) .map_err(|source| LaunchRefusal::SessionState { source })?, ); + let lifecycle = Arc::new(RunLifecycle::new()); let waits = Arc::new(WaitRegistry::new()); let (input_frames, _) = broadcast::channel(INPUT_CAPACITY); let (deltas, _) = broadcast::channel(DELTA_CAPACITY); @@ -245,16 +243,17 @@ 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), + closed: Notify::new(), }); self.lock().insert(id, Arc::clone(&session)); - spawn_supervisor( + supervisor::spawn( Arc::clone(&session), self.clone(), self.inner.host.clone(), @@ -299,14 +298,7 @@ impl AgentSessions { after_acceptance: impl FnOnce(), ) -> Option> { let session = self.get(id)?; - Some(deliver_input_response_before_completion( - session.log.as_ref(), - &session.waits, - &session.id, - &session.agent, - response, - after_acceptance, - )) + Some(session.accept_input(response, after_acceptance)) } /// The session map guard; a lock poisoned by a panicking peer @@ -363,6 +355,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. @@ -377,12 +371,11 @@ 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, + /// Wakes a supervisor that is waiting for its first usable catalog. + closed: Notify, } impl fmt::Debug for AgentSession { @@ -406,12 +399,36 @@ impl AgentSession { self.errors.subscribe() } + /// Durably accepts one input and resumes its wait while excluding a + /// catalog cancellation from the observation-to-completion boundary. + pub(crate) fn accept_input( + &self, + response: InputResponse, + after_acceptance: impl FnOnce(), + ) -> Result<(), WaitError> { + let mut state = self.lifecycle.lock(); + let previously_accepted = state.accepted_turn; + state.accepted_turn = true; + let result = deliver_input_response_before_completion( + self.log.as_ref(), + &self.waits, + &self.id, + &self.agent, + response, + after_acceptance, + ); + if result.is_err() { + state.accepted_turn = previously_accepted; + } + 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.cancel(CancelOrigin::Operator); } /// Ends the session: the run is cancelled and the supervisor stops @@ -419,12 +436,12 @@ impl AgentSession { fn close(&self) { self.closing.store(true, Ordering::SeqCst); self.cancel_turn(); + self.closed.notify_waiters(); } /// Installs and retains the next run's fresh cancel handle. fn arm_cancel(&self) -> CancelHandle { - let fresh = CancelHandle::new(); - *self.cancel_guard() = fresh.clone(); + let fresh = self.lifecycle.arm(); // 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) { @@ -433,9 +450,19 @@ impl AgentSession { fresh } - /// 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) + /// Requests catalog retirement, deferring while accepted input is active. + fn cancel_for_catalog(&self) -> bool { + self.lifecycle.cancel_for_catalog() + } + + /// Waits until the accepted turn reaches a terminal event. + async fn wait_until_turn_settled(&self) { + self.lifecycle.wait_until_settled().await; + } + + /// Returns why the current run was cancelled. + fn cancel_origin(&self) -> Option { + self.lifecycle.origin() } } @@ -456,6 +483,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 { @@ -466,6 +495,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_turn(); let message = format!("{event} in agent `{section}`"); let _ = self.errors.send(message.clone()); // The failed round never reaches on_assistant_reply, so this @@ -501,6 +531,7 @@ impl Observer for SessionObserver { model, metrics, ); + self.lifecycle.settle_turn(); self.rounds.fetch_add(1, Ordering::SeqCst); self.backoff.record_useful_work(); self.push.push_idle(); @@ -567,100 +598,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 @@ -815,17 +752,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) => { @@ -1089,6 +1022,7 @@ mod tests { push: Push::new(status, catalog, menu), backoff: ReconnectBackoff::new(), errors, + lifecycle: Arc::new(RunLifecycle::new()), }; 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..37a1bc85 --- /dev/null +++ b/crates/workshop-server/src/session_agents/lifecycle.rs @@ -0,0 +1,102 @@ +//! Cancellation provenance and accepted-turn settlement. + +use std::sync::{Mutex, MutexGuard, PoisonError}; + +use promptforge_core_support::cancel::CancelHandle; +use tokio::sync::Notify; + +/// Why the current run's cancellation handle fired. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum CancelOrigin { + /// The operator explicitly cancelled the current turn. + Operator, + /// The supervisor retired an idle run for a new catalog generation. + Catalog, +} + +/// State shared by input acceptance, the supervisor, and terminal events. +pub(super) struct RunLifecycle { + state: Mutex, + settled: Notify, +} + +/// The current run's cancellation and accepted-turn state. +pub(super) struct RunState { + cancel: CancelHandle, + origin: Option, + pub(super) accepted_turn: bool, +} + +impl RunLifecycle { + /// Creates the lifecycle before the first run is armed. + pub(super) fn new() -> Self { + Self { + state: Mutex::new(RunState { + cancel: CancelHandle::new(), + origin: None, + accepted_turn: false, + }), + settled: Notify::new(), + } + } + + /// Locks lifecycle state, recovering from a panicking peer. + pub(super) fn lock(&self) -> MutexGuard<'_, RunState> { + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Arms a fresh run and clears the prior stop provenance. + pub(super) fn arm(&self) -> CancelHandle { + let fresh = CancelHandle::new(); + let mut state = self.lock(); + state.cancel = fresh.clone(); + state.origin = None; + state.accepted_turn = false; + fresh + } + + /// Cancels immediately for an operator request. + pub(super) fn cancel(&self, origin: CancelOrigin) { + let mut state = self.lock(); + state.origin = Some(origin); + state.accepted_turn = false; + state.cancel.cancel(); + self.settled.notify_waiters(); + } + + /// Cancels for catalog replacement only when no accepted turn is active. + pub(super) fn cancel_for_catalog(&self) -> bool { + let mut state = self.lock(); + if state.accepted_turn { + return false; + } + state.origin = Some(CancelOrigin::Catalog); + state.cancel.cancel(); + true + } + + /// Records that the accepted turn reached a durable terminal event. + pub(super) fn settle_turn(&self) { + let mut state = self.lock(); + if state.accepted_turn { + state.accepted_turn = false; + self.settled.notify_waiters(); + } + } + + /// Waits cancellation-safely until no accepted turn remains. + pub(super) async fn wait_until_settled(&self) { + loop { + let notified = self.settled.notified(); + if !self.lock().accepted_turn { + return; + } + notified.await; + } + } + + /// Returns the current run's cancellation provenance. + pub(super) fn origin(&self) -> Option { + self.lock().origin + } +} 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..3f74d74b --- /dev/null +++ b/crates/workshop-server/src/session_agents/supervisor.rs @@ -0,0 +1,199 @@ +//! Agent-run supervision across cancellation and catalog generations. + +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use promptforge_agent::{AgentConfig, AgentError, AgentLimits, run_agent_with_client}; +use promptforge_core_support::observe::Observer; +use promptforge_model_client::client::GatewayClient as ModelClient; +use promptforge_store::StoreRef; +use promptforge_tools::{Tool, ToolCatalog}; + +use crate::catalog::{CatalogBus, ChatCatalog}; +use crate::input::UserInputTool; +use crate::protocol::Activity; + +use super::{ + AgentSession, AgentSessions, CancelOrigin, SessionHost, SessionObserver, build_model_catalog, + delta_stamp, ui_provider, +}; + +/// 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, + client: ModelClient, +) { + 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 store = StoreRef::memory(); + let observer = observer(&session, &host); + let on_delta = delta_stamp(&session, &host.push); + let ui = ui_provider(&host.menu, &host.workspace); + let mut catalog_generation = host.catalog.subscribe_chat_generation(); + loop { + let Some(chat_catalog) = + wait_for_chat_catalog(&session, &host.catalog, &mut catalog_generation).await + else { + break; + }; + let active_generation = chat_catalog.generation; + let active_models = chat_catalog.models; + let models = build_model_catalog(Some(active_models.clone())); + let run_cancel = session.arm_cancel(); + let config = AgentConfig { + name: session.agent.clone(), + execution: session.id.clone(), + observer: Arc::clone(&observer), + cancel: run_cancel.clone(), + event_log: Some(Arc::clone(&session.log) as _), + on_delta: Some(Arc::clone(&on_delta)), + ui: Some(Arc::clone(&ui)), + limits: AgentLimits::default(), + }; + let run = run_agent_with_client( + &session.source, + &tools, + &models, + &store, + config, + Some(client.clone()), + ); + tokio::pin!(run); + let result = tokio::select! { + result = &mut run => result, + replacement = wait_for_replacement_catalog( + &host.catalog, + &mut catalog_generation, + active_generation, + &active_models, + ) => { + if replacement.is_none() { + run.await + } else { + loop { + if session.cancel_for_catalog() { + break run.await; + } + tokio::select! { + result = &mut run => break result, + () = session.wait_until_turn_settled() => {} + } + } + } + } + }; + match (result, session.cancel_origin()) { + (Err(AgentError::Interrupted), _) => { + if session.closing.load(Ordering::SeqCst) { + break; + } + report_cancel_origin(&session); + } + (Ok(()), _) => break, + (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); + break; + } + } + } + registry.forget(&session.id); + }); +} + +/// Builds the observer shared by every generation of one session. +fn observer(session: &AgentSession, host: &SessionHost) -> 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), + }) +} + +/// Records catalog retirement separately from explicit operator cancellation. +fn report_cancel_origin(session: &AgentSession) { + match session.cancel_origin() { + Some(CancelOrigin::Operator) => {} + Some(CancelOrigin::Catalog) => tracing::debug!( + session = %session.id, + "agent run retired for a new catalog generation" + ), + None => tracing::debug!( + session = %session.id, + "agent run interrupted without a supervisor cancellation origin" + ), + } +} + +/// Waits for the first non-empty chat catalog or session close. +async fn wait_for_chat_catalog( + session: &AgentSession, + catalog: &CatalogBus, + generation: &mut tokio::sync::watch::Receiver, +) -> Option { + loop { + let closed = session.closed.notified(); + tokio::pin!(closed); + if session.closing.load(Ordering::SeqCst) { + return None; + } + if let Some(chat) = catalog.latest_chat() { + return Some(chat); + } + tokio::select! { + () = &mut closed => {} + changed = generation.changed() => { + if changed.is_err() { + return None; + } + } + } + } +} + +/// Waits for a usable generation with bindings different from this run. +/// Empty snapshots let an accepted dispatch report binding loss, while +/// restoring identical bindings needs no relaunch. +async fn wait_for_replacement_catalog( + catalog: &CatalogBus, + generation: &mut tokio::sync::watch::Receiver, + active_generation: u64, + active_models: &[serde_json::Value], +) -> Option { + loop { + if generation.changed().await.is_err() { + return None; + } + if let Some(chat) = catalog.latest_chat() + && chat.generation != active_generation + && chat.models != active_models + { + return Some(chat); + } + } +} diff --git a/crates/workshop-server/tests/it/chat_gate.rs b/crates/workshop-server/tests/it/chat_gate.rs index ea3472d9..7c029c41 100644 --- a/crates/workshop-server/tests/it/chat_gate.rs +++ b/crates/workshop-server/tests/it/chat_gate.rs @@ -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; @@ -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 { @@ -143,13 +158,34 @@ async fn spawn_chat_server(models: &[&str]) -> GateServer { 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 { @@ -229,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"] @@ -749,3 +794,237 @@ async fn gate_binding_loss_surfaces_one_error_and_recovers_after_selection() { ); 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/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index ee683181..7451ae98 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -74,7 +74,7 @@ isProject: false - 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 37 are the sole logging exception. + - 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 39 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. @@ -371,7 +371,7 @@ isProject: false 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 34, adds only `workshop-server -> shared-loopback`. Final Phase C, Step 35 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. +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 36, adds only `workshop-server -> shared-loopback`. Final Phase C, Step 37 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] @@ -731,7 +731,28 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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: Pass installed Windows microphone acceptance +### 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 + +- 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: Pass installed Windows microphone acceptance - 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. @@ -742,9 +763,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 33; the operator must exercise the installed application and record passing evidence, which alone gates legacy removal. +- Consumes and gates: consumes Steps 30 through 35; the operator must exercise the installed application and record passing evidence, which alone gates legacy removal. -### Step 35: Remove legacy seams and tests +### Step 37: Remove legacy seams and tests - 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. @@ -758,7 +779,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 36: Finalize architecture and documentation +### Step 38: Finalize architecture and documentation - 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. @@ -768,9 +789,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 35 final topology; final verification starts only with zero temporary exceptions. +- Consumes and gates: consumes Step 37 final topology; final verification starts only with zero temporary exceptions. -### Step 37: Bookend Gateway serving logs +### Step 39: Bookend Gateway serving logs - 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. @@ -778,9 +799,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 36 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 38's full release verification must pass after this change. +- Consumes and gates: this operator-requested observability correction is independent of STT and follows Step 38 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 40's full release verification must pass after this change. -### Step 38: Run every release gate and repeat acceptance +### Step 40: Run every release gate and repeat acceptance - Artifacts: append command results, hashes, ratchet counts, native equivalence, generated-doc cleanliness, and repeated installed-microphone evidence to `design/generic-realtime-stt-acceptance.md`; change no implementation. - Scope: run every exit criterion independently under PowerShell 5.1 and stop on any failure. @@ -814,6 +835,6 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 37, then repeats the Step 34 installed-package microphone scenarios. Completion requires every command, ratchet, generated-doc check, and physical scenario to pass with no open finding. +- Consumes and gates: consumes Step 39, then repeats the Step 36 installed-package microphone scenarios. 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 37's exact two records, or completed Workshop baseline-ratchet work. VAD tuning, alignment, 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 +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 39's exact two records, or completed Workshop baseline-ratchet work. VAD tuning, alignment, 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/archdoc-next.md b/vibe/archdoc-next.md index a13ae41b..4adf4c86 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -174,8 +174,8 @@ N45 | observation | shared-parameter-cluster @ crates/gateway/src/lib.rs::reques 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 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 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 -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 +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 +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 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 From e7216d92c58f50d0c9b967bf4123e877b922cf47 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 02:35:08 -0700 Subject: [PATCH 44/86] Partition live hypotheses into disjoint fields Build each live hypothesis from one producer-owned snapshot. Keep each leading separator on the finalized, agreed, or tentative field that introduces its text, so direct concatenation preserves exact spacing without duplicate prefixes. Use the same snapshot for legacy deltas and Realtime events. - `InterimSnapshot` derives `transcript` once from its private `finalized`, `agreed`, and `tentative` fields. `ServerEvent::hypothesis` consumes that snapshot instead of assembling overlapping text. - `InterimState::next` retains promoted words separately, reconciles divergent final text, and assigns boundary whitespace through `owned_piece`. - `producer_hypothesis_ownership` drives producer-generated snapshots through Gateway serialization and Workshop replacement. It checks exact fields, spaces, revisions, and the absence of a duplicated prefix. Design: new surface-growth @ crates/gateway-stt-engine/src/test_fixtures.rs::ScriptedDecoder::wait_for_completed boundary: pub Design: extends shared-mutable-state @ crates/gateway-stt-engine/src/test_fixtures.rs::ScriptedDecoder Design: extends temporal-coupling @ crates/gateway-stt-engine/src/test_fixtures.rs::ScriptedDecoder Design: removes oversized-unit @ crates/gateway-stt/src/take/agreement.rs Design: new parameter-object @ crates/gateway-stt/src/take/interim.rs::InterimSnapshot boundary: wire Design: new encapsulated-invariant @ crates/gateway-stt/src/take/interim.rs::InterimSnapshot boundary: wire Design: new oversized-unit @ crates/gateway-stt/src/take/interim.rs Design: new pure-function @ crates/gateway-stt/src/take/interim.rs::after_token_prefix deps: &str,usize Design: new pure-function @ crates/gateway-stt/src/take/interim.rs::owned_piece deps: &str,bool Design: new pure-function @ crates/gateway/tests/it/realtime_stt.rs::audio_samples deps: &[i16] Design: new pure-function @ crates/gateway/tests/it/realtime_stt.rs::closed_segment Design: extends oversized-unit @ crates/gateway/tests/it/realtime_stt.rs Design: new oversized-unit @ crates/gateway/tests/it/realtime_stt.rs::producer_snapshots_partition_finalized_agreed_and_tentative_text Violates: A2 - credential ownership in crates/gateway-stt/src/realtime is not determinable from diff Pending: N6 - compounds Pending: N20 - compounds Pending: N21 - compounds Pending: N22 - compounds Pending: N30 - compounds Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- .../gateway-stt-engine/module-ceilings.toml | 2 +- .../gateway-stt-engine/src/test_fixtures.rs | 14 +- crates/gateway-stt/module-ceilings.toml | 5 +- .../gateway-stt/src/realtime/session/route.rs | 16 +- .../src/realtime/wire/server/events.rs | 8 +- crates/gateway-stt/src/take.rs | 7 +- crates/gateway-stt/src/take/agreement.rs | 64 +------- crates/gateway-stt/src/take/interim.rs | 145 ++++++++++++++++++ crates/gateway-stt/src/take/state.rs | 2 +- .../fixtures/realtime/valid-sequences.json | 12 ++ .../gateway-stt/tests/it/realtime_fixtures.rs | 1 + crates/gateway/tests/it/realtime_stt.rs | 128 ++++++++++++++++ crates/workshop-server/ui/test/agent-stt.mjs | 35 +++++ vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- vibe/archdoc-next.md | 10 +- 15 files changed, 363 insertions(+), 88 deletions(-) create mode 100644 crates/gateway-stt/src/take/interim.rs diff --git a/crates/gateway-stt-engine/module-ceilings.toml b/crates/gateway-stt-engine/module-ceilings.toml index 70d89b85..82dbabc5 100644 --- a/crates/gateway-stt-engine/module-ceilings.toml +++ b/crates/gateway-stt-engine/module-ceilings.toml @@ -13,6 +13,6 @@ public_root_budget = 7 "lib.rs" = 18 "policy.rs" = 132 "startup.rs" = 48 -"test_fixtures.rs" = 657 +"test_fixtures.rs" = 667 "translation.rs" = 50 "worker.rs" = 460 diff --git a/crates/gateway-stt-engine/src/test_fixtures.rs b/crates/gateway-stt-engine/src/test_fixtures.rs index ca63e5fa..db674abf 100644 --- a/crates/gateway-stt-engine/src/test_fixtures.rs +++ b/crates/gateway-stt-engine/src/test_fixtures.rs @@ -37,6 +37,7 @@ struct DecoderState { outcomes: VecDeque, construction_errors: VecDeque, requests: Vec, + completed: usize, creation_thread: Option, decode_threads: Vec, waiters: usize, @@ -118,6 +119,12 @@ impl ScriptedDecoder { 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) + } + /// Waits until a parked decode has entered its rendezvous. #[must_use] pub fn wait_until_parked(&self, timeout: Duration) -> bool { @@ -219,14 +226,17 @@ impl Decoder for WorkerDecoder { .unwrap_or_else(PoisonError::into_inner); state.park = ParkState::Ready; } - match state.outcomes.pop_front() { + 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 } } diff --git a/crates/gateway-stt/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index 332a3f77..351c2bde 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -41,9 +41,10 @@ destination = "removal after the Realtime route and Workshop relay replace the l "service.rs" = 134 "status.rs" = 54 "stt.rs" = 725 -"take.rs" = 420 -"take/agreement.rs" = 116 +"take.rs" = 417 +"take/agreement.rs" = 62 "take/finalization.rs" = 177 +"take/interim.rs" = 145 "take/state.rs" = 82 "take/text.rs" = 9 "test_fixtures.rs" = 444 diff --git a/crates/gateway-stt/src/realtime/session/route.rs b/crates/gateway-stt/src/realtime/session/route.rs index 17cf8360..65ee046c 100644 --- a/crates/gateway-stt/src/realtime/session/route.rs +++ b/crates/gateway-stt/src/realtime/session/route.rs @@ -53,17 +53,17 @@ impl Session { if transcript.is_empty() { return Ok(None); } - let finalized = input.take().finalized(); - let update = input.take().next_interim(&transcript); + let update = input.take().next_interim_snapshot(&transcript); if !include_hypothesis { - if let Some((committed, _)) = update { + if let Some(snapshot) = update { + let committed = snapshot.committed(); let delta = committed .strip_prefix(&self.standard_interim_committed) .ok_or(SessionError::Inference)?; if !delta.is_empty() { self.pending_interim.push(delta.to_owned()); } - self.standard_interim_committed = committed; + committed.clone_into(&mut self.standard_interim_committed); } return Ok(None); } @@ -71,14 +71,14 @@ impl Session { .hypothesis_revision .checked_add(1) .ok_or(SessionError::EpochExhausted)?; - let (agreed, tentative) = update.unwrap_or_else(|| (String::new(), transcript)); + let Some(snapshot) = update else { + return Ok(None); + }; Ok(Some(ServerEvent::hypothesis( self.ids.event(), input.item_id().to_owned(), self.hypothesis_revision, - finalized, - agreed, - tentative, + snapshot, u64::try_from(Duration::from_secs_f64(input.buffered_duration_seconds()).as_millis()) .unwrap_or(u64::MAX), ))) diff --git a/crates/gateway-stt/src/realtime/wire/server/events.rs b/crates/gateway-stt/src/realtime/wire/server/events.rs index 40a4edfb..e3c4dd84 100644 --- a/crates/gateway-stt/src/realtime/wire/server/events.rs +++ b/crates/gateway-stt/src/realtime/wire/server/events.rs @@ -3,6 +3,7 @@ use super::{ }; 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( @@ -27,17 +28,16 @@ impl ServerEvent { event_id: String, item_id: String, revision: u64, - finalized: String, - agreed: String, - tentative: String, + snapshot: InterimSnapshot, audio_end_ms: u64, ) -> Self { + let (transcript, finalized, agreed, tentative) = snapshot.into_parts(); Self::TranscriptionHypothesis { event_id, item_id, content_index: 0, revision, - transcript: format!("{finalized}{agreed}{tentative}"), + transcript, finalized, agreed, tentative, diff --git a/crates/gateway-stt/src/take.rs b/crates/gateway-stt/src/take.rs index da50a390..d0fab240 100644 --- a/crates/gateway-stt/src/take.rs +++ b/crates/gateway-stt/src/take.rs @@ -9,6 +9,7 @@ use crate::generation::GenerationLease; mod agreement; mod finalization; +mod interim; mod state; mod text; @@ -17,6 +18,7 @@ use agreement::LocalAgreement; #[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 text::append_transcript; @@ -125,11 +127,6 @@ impl Take { self.state.take_failure() } - pub(crate) fn next_interim(&self, hypothesis: &str) -> Option<(String, String)> { - let finalized = self.finalized(); - TakeState::lock(&self.state.interim).next(&finalized, hypothesis) - } - pub(crate) fn finalization(&self) -> Option { let pipeline = self.final_pipeline.as_ref()?; let consumed = self.consumed(); diff --git a/crates/gateway-stt/src/take/agreement.rs b/crates/gateway-stt/src/take/agreement.rs index 70ee050c..484aeed7 100644 --- a/crates/gateway-stt/src/take/agreement.rs +++ b/crates/gateway-stt/src/take/agreement.rs @@ -1,5 +1,3 @@ -use super::text::append_transcript; - #[derive(Debug, PartialEq, Eq)] pub(super) struct AgreementSnapshot { pub(super) agreed: String, @@ -25,57 +23,14 @@ impl LocalAgreement { tentative: hypothesis[agreed_end..].to_owned(), } } -} - -#[derive(Debug, Default)] -pub(super) struct InterimState { - agreement: LocalAgreement, - promoted: String, - agreement_finalized: String, - committed: String, - last_committed: String, - last_tentative: String, - finalized_at_last_speech: String, -} -impl InterimState { - pub(super) fn next(&mut self, finalized: &str, hypothesis: &str) -> Option<(String, String)> { - if self.agreement_finalized != finalized { - let finalized_delta = finalized - .strip_prefix(&self.agreement_finalized) - .unwrap_or_default(); - let unpromoted = after_token_prefix(finalized_delta, token_spans(&self.promoted).len()); - append_transcript(&mut self.committed, unpromoted.trim()); - self.agreement = LocalAgreement::default(); - self.promoted.clear(); - self.agreement_finalized.clear(); - self.agreement_finalized.push_str(finalized); - } - let suffix_start = matching_token_prefix_end(&self.promoted, hypothesis); - let suffix = hypothesis[suffix_start..].trim_start(); - let agreement = self.agreement.observe(suffix); - let tentative = agreement.tentative.trim_start().to_owned(); - self.agreement.previous.clone_from(&tentative); - let promoted = agreement.agreed.trim(); - append_transcript(&mut self.promoted, promoted); - append_transcript(&mut self.committed, promoted); - if !hypothesis.is_empty() { - self.finalized_at_last_speech.clear(); - self.finalized_at_last_speech.push_str(finalized); - } else if finalized.len() <= self.finalized_at_last_speech.len() { - return None; - } - let committed = self.committed.clone(); - if committed == self.last_committed && tentative == self.last_tentative { - return None; - } - self.last_committed.clone_from(&committed); - self.last_tentative.clone_from(&tentative); - Some((committed, tentative)) + pub(super) fn retain_tentative(&mut self, tentative: &str) { + self.previous.clear(); + self.previous.push_str(tentative); } } -fn matching_token_prefix_end(previous: &str, current: &str) -> usize { +pub(super) fn matching_token_prefix_end(previous: &str, current: &str) -> usize { let previous = token_spans(previous); let current = token_spans(current); previous @@ -87,7 +42,7 @@ fn matching_token_prefix_end(previous: &str, current: &str) -> usize { .unwrap_or(0) } -fn token_spans(text: &str) -> Vec<(&str, usize, usize)> { +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 @@ -105,12 +60,3 @@ fn token_spans(text: &str) -> Vec<(&str, usize, usize)> { } tokens } - -fn after_token_prefix(text: &str, tokens: usize) -> &str { - if tokens == 0 { - return text; - } - token_spans(text) - .get(tokens - 1) - .map_or("", |(_, _, end)| &text[*end..]) -} diff --git a/crates/gateway-stt/src/take/interim.rs b/crates/gateway-stt/src/take/interim.rs new file mode 100644 index 00000000..3171dc68 --- /dev/null +++ b/crates/gateway-stt/src/take/interim.rs @@ -0,0 +1,145 @@ +use super::agreement::{LocalAgreement, matching_token_prefix_end, token_spans}; +use super::text::append_transcript; +use super::{Take, TakeState}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct InterimSnapshot { + transcript: String, + finalized: String, + agreed: String, + tentative: String, +} + +impl InterimSnapshot { + 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) + } + + pub(crate) fn into_legacy_parts(self) -> (String, String) { + ( + self.committed().to_owned(), + self.tentative.trim_start().to_owned(), + ) + } +} + +#[derive(Debug, Default)] +pub(super) struct InterimState { + agreement: LocalAgreement, + promoted: String, + agreement_finalized: String, + finalized: String, + last: Option, + finalized_at_last_speech: String, +} + +impl InterimState { + pub(super) fn next(&mut self, finalized: &str, hypothesis: &str) -> Option { + if self.agreement_finalized != finalized { + let finalized_delta = finalized + .strip_prefix(&self.agreement_finalized) + .unwrap_or_default(); + let unpromoted = after_token_prefix(finalized_delta, token_spans(&self.promoted).len()); + append_transcript(&mut self.finalized, &self.promoted); + append_transcript(&mut self.finalized, unpromoted.trim()); + self.agreement = LocalAgreement::default(); + self.promoted.clear(); + self.agreement_finalized.clear(); + self.agreement_finalized.push_str(finalized); + } + let suffix_start = matching_token_prefix_end(&self.promoted, hypothesis); + let suffix = &hypothesis[suffix_start..]; + let agreement = self.agreement.observe(suffix); + let mut tentative = agreement.tentative; + self.agreement.retain_tentative(&tentative); + append_transcript(&mut self.promoted, agreement.agreed.trim()); + if !hypothesis.is_empty() { + self.finalized_at_last_speech.clear(); + self.finalized_at_last_speech.push_str(finalized); + } else if finalized.len() <= self.finalized_at_last_speech.len() { + return None; + } + let agreed = owned_piece(!self.finalized.is_empty(), &self.promoted); + if suffix_start == 0 && !self.finalized.is_empty() && self.promoted.is_empty() { + tentative = owned_piece(true, &tentative); + } + let snapshot = InterimSnapshot::new(self.finalized.clone(), agreed, tentative); + if self.last.as_ref() == Some(&snapshot) { + return (!hypothesis.is_empty()).then_some(snapshot); + } + self.last = Some(snapshot.clone()); + Some(snapshot) + } +} + +impl Take { + pub(crate) fn next_interim(&self, hypothesis: &str) -> Option<(String, String)> { + self.next_interim_snapshot(hypothesis) + .map(InterimSnapshot::into_legacy_parts) + } + + pub(crate) fn next_interim_snapshot(&self, hypothesis: &str) -> Option { + let finalized = self.finalized(); + TakeState::lock(&self.state.interim).next(&finalized, hypothesis) + } +} + +fn after_token_prefix(text: &str, tokens: usize) -> &str { + if tokens == 0 { + return text; + } + token_spans(text) + .get(tokens - 1) + .map_or("", |(_, _, end)| &text[*end..]) +} + +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 gateway_stt_engine::TranscribeError; + + use super::Take; + + #[test] + fn snapshot_fields_own_disjoint_exact_text_after_divergent_finalization() { + let take = Take::without_final(Vec::new()); + take.next_interim("ask not your country"); + take.next_interim("ask not your country"); + take.record_finalized(Ok::<_, TranscribeError>("ask not your kingdom".to_owned())); + take.next_interim("new tail first"); + let snapshot = take + .next_interim_snapshot("new tail second") + .expect("new speech emits a partitioned snapshot"); + + assert_eq!( + snapshot.into_parts(), + ( + "ask not your country new tail second".to_owned(), + "ask not your country".to_owned(), + " new tail".to_owned(), + " second".to_owned() + ) + ); + } +} diff --git a/crates/gateway-stt/src/take/state.rs b/crates/gateway-stt/src/take/state.rs index 8c2c9226..67467de4 100644 --- a/crates/gateway-stt/src/take/state.rs +++ b/crates/gateway-stt/src/take/state.rs @@ -2,7 +2,7 @@ use std::sync::{Mutex, MutexGuard, PoisonError}; use gateway_stt_engine::TranscribeError; -use super::agreement::InterimState; +use super::interim::InterimState; use super::text::append_transcript; use crate::segment::Segmenter; diff --git a/crates/gateway-stt/tests/fixtures/realtime/valid-sequences.json b/crates/gateway-stt/tests/fixtures/realtime/valid-sequences.json index aef910ba..ebccf775 100644 --- a/crates/gateway-stt/tests/fixtures/realtime/valid-sequences.json +++ b/crates/gateway-stt/tests/fixtures/realtime/valid-sequences.json @@ -82,6 +82,18 @@ "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" } }, diff --git a/crates/gateway-stt/tests/it/realtime_fixtures.rs b/crates/gateway-stt/tests/it/realtime_fixtures.rs index 0d5832e9..419259a1 100644 --- a/crates/gateway-stt/tests/it/realtime_fixtures.rs +++ b/crates/gateway-stt/tests/it/realtime_fixtures.rs @@ -51,6 +51,7 @@ const VALID_SEQUENCE_CASES: &[&str] = &[ "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", diff --git a/crates/gateway/tests/it/realtime_stt.rs b/crates/gateway/tests/it/realtime_stt.rs index 57e4f1ec..2de77489 100644 --- a/crates/gateway/tests/it/realtime_stt.rs +++ b/crates/gateway/tests/it/realtime_stt.rs @@ -146,11 +146,36 @@ async fn send(socket: &mut Socket, value: serde_json::Value) { .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 { let bytes = vec![0_u8; 24_000 * 2 / 10]; base64::engine::general_purpose::STANDARD.encode(bytes) } +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 closed_segment() -> String { + let mut samples = vec![16_384; 24_000]; + samples.extend(vec![0; 72_000]); + audio_samples(&samples) +} + fn canonical_sequences() -> serde_json::Value { let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("..") @@ -334,6 +359,109 @@ async fn canonical_fixture_drives_hypothesis_completion_and_clear() { server.shutdown().await; } +#[tokio::test] +async fn producer_snapshots_partition_finalized_agreed_and_tentative_text() { + let fixtures = canonical_sequences(); + let interim = ScriptedDecoder::new(); + for transcript in [ + "ask not your country", + "ask not your country", + "new tail first", + "new tail second", + ] { + interim.push_text(transcript); + } + let final_decoder = ScriptedDecoder::new(); + final_decoder.park_next(); + final_decoder.push_text("ask not your kingdom"); + final_decoder.push_text("second final"); + 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; + send( + &mut socket, + canonical_client(&fixtures, "producer_hypothesis_ownership", "session.update"), + ) + .await; + expect_type(&mut socket, "session.updated").await; + + append_audio(&mut socket, closed_segment()).await; + expect_type( + &mut socket, + "conversation.item.input_audio_transcription.hypothesis", + ) + .await; + append_audio(&mut socket, audio()).await; + expect_type( + &mut socket, + "conversation.item.input_audio_transcription.hypothesis", + ) + .await; + + assert!(final_decoder.wait_until_parked(PHASE_TIMEOUT)); + interim.park_next(); + final_decoder.release(); + let completed_final = final_decoder.clone(); + assert!( + tokio::task::spawn_blocking(move || completed_final.wait_for_completed(1, PHASE_TIMEOUT)) + .await + .expect("final completion observer joins") + ); + final_decoder.park_next(); + append_audio(&mut socket, closed_segment()).await; + let parked_interim = interim.clone(); + assert!( + tokio::task::spawn_blocking(move || parked_interim.wait_until_parked(PHASE_TIMEOUT)) + .await + .expect("interim park observer joins") + ); + assert!( + final_decoder.wait_for_requests(2, PHASE_TIMEOUT), + "the first closed segment is finalized before snapshot assembly" + ); + interim.release(); + + let third = expect_type( + &mut socket, + "conversation.item.input_audio_transcription.hypothesis", + ) + .await; + append_audio(&mut socket, audio()).await; + let fourth = expect_type( + &mut socket, + "conversation.item.input_audio_transcription.hypothesis", + ) + .await; + + for (actual, occurrence) in [(third, 0), (fourth, 1)] { + let expected = canonical_message( + &fixtures, + "producer_hypothesis_ownership", + "server", + "conversation.item.input_audio_transcription.hypothesis", + occurrence, + ); + for field in [ + "revision", + "transcript", + "finalized", + "agreed", + "tentative", + "audio_start_ms", + "audio_end_ms", + ] { + assert_eq!(actual[field], expected[field], "{field}: {actual}"); + } + } + + final_decoder.release(); + socket.close(None).await.expect("socket closes"); + drop(socket); + server.shutdown().await; +} + #[tokio::test] async fn gateway_auth_origin_query_and_legacy_surfaces_precede_upgrade() { let service = speech(&ScriptedDecoder::new(), Some(&ScriptedDecoder::new())); diff --git a/crates/workshop-server/ui/test/agent-stt.mjs b/crates/workshop-server/ui/test/agent-stt.mjs index a9d92af9..4dd57a70 100644 --- a/crates/workshop-server/ui/test/agent-stt.mjs +++ b/crates/workshop-server/ui/test/agent-stt.mjs @@ -666,6 +666,41 @@ 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(); + } + // --- Takes insert at the cursor ------------------------------------------- { diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 7451ae98..20a3bc9a 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -741,7 +741,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 4adf4c86..d607ab62 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -131,7 +131,7 @@ N2 | observation | Violates A96 @ crates/workshop-server/ui/test/realtime-wire-f 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 +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 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 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 @@ -145,9 +145,9 @@ N16 | observation | clone-block @ crates/gateway-stt/tests/common/mod.rs: duplic N17 | observation | Violates A2 @ crates/gateway-stt/tests/it/architecture.rs: not determinable from diff | Enforce exact STT architecture ratchets 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 -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 -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 -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 +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 +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 +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 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 @@ -155,7 +155,7 @@ N26 | observation | constructor-injection @ crates/gateway-stt/src/runtime.rs::S 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 +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 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 From 006ba06d945ec0bfacbb0a0270f65d2706eb20db Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 03:21:46 -0700 Subject: [PATCH 45/86] Schedule and rebase whole-window hypotheses Drive interim decoding on the configured engine cadence instead of on every audio append, suppressing undersized and silent windows while allowing one active decode and coalescing later audio into the next snapshot. Rebase each whole-window replacement by its sample origin and token overlap, while keeping finalized text and its sample watermark atomic and authoritative. Reap completed canceled work during completion ticks so repeated clears release bounded task capacity. - `run_socket` separates interim scheduling cadence from completion polling, and `Session::schedule_interim` accepts only the newest eligible snapshot after the active decode finishes. - `WholeWindowState` replaces same-origin hypotheses, carries text across consumed segment boundaries, and rebases advancing origins through explicit token overlap. `ServerEvent::hypothesis` reports the accepted window's sample offsets in milliseconds. - `TakeState::finalized_snapshot` reads finalized text and its consumed-sample watermark under one lock so rebase decisions cannot combine different finalization states. - `realtime_stt_native_incremental` adds ignored packaged-native coverage for growing and sliding JFK audio. Final Verify skipped its execution because the external Whisper library, model, and audio fixtures were unavailable. Design: extends oversized-unit @ crates/gateway-stt/src/realtime/route.rs Design: extends oversized-unit @ crates/gateway-stt/src/realtime/session.rs Design: extends oversized-unit @ crates/gateway-stt/src/realtime/session/route.rs Design: new pure-function @ crates/gateway-stt/src/realtime/session/route.rs::sample_millis deps: usize Design: extends oversized-unit @ crates/gateway-stt/src/realtime/wire/server/events.rs Design: extends oversized-unit @ crates/gateway-stt/src/take.rs Design: new shared-parameter-cluster @ crates/gateway-stt/src/take.rs::Take::next_window_snapshot Design: extends oversized-unit @ crates/gateway-stt/src/take/interim.rs Design: extends oversized-unit @ crates/gateway-stt/src/take/state.rs Design: new oversized-unit @ crates/gateway-stt/src/take/window.rs Design: new shared-parameter-cluster @ crates/gateway-stt/src/take/window.rs::WholeWindowState::next Design: new pure-function @ crates/gateway-stt/src/take/window.rs::rebase_sliding_window deps: &str,&str Design: new pure-function @ crates/gateway-stt/src/take/window.rs::equivalent_token deps: &str,&str Design: new pure-function @ crates/gateway-stt/src/take/window.rs::owned_piece deps: &str,bool Design: extends oversized-unit @ crates/gateway/tests/it/realtime_stt.rs Design: new oversized-unit @ crates/gateway/tests/it/realtime_stt.rs::interim_scheduler_enforces_cadence_minimum_silence_and_coalescing Design: removes oversized-unit @ crates/gateway/tests/it/realtime_stt.rs::producer_snapshots_partition_finalized_agreed_and_tentative_text Design: new hidden-dependency @ crates/gateway/tests/it/realtime_stt.rs::native_fixture deps: &str,&str Design: new pure-function @ crates/gateway/tests/it/realtime_stt.rs::normalized_words deps: &str Violates: A2 - credential ownership in crates/gateway-stt/src/realtime is not determinable from diff Pending: N6 - compounds Pending: N30 - compounds Deferred: packaged-native test execution awaits external fixtures Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- Cargo.lock | 1 + crates/gateway-stt/module-ceilings.toml | 19 +- crates/gateway-stt/src/realtime/route.rs | 56 +- crates/gateway-stt/src/realtime/session.rs | 53 +- .../gateway-stt/src/realtime/session/items.rs | 1 + .../gateway-stt/src/realtime/session/route.rs | 111 +++- .../gateway-stt/src/realtime/session/state.rs | 17 +- .../src/realtime/wire/server/events.rs | 3 +- crates/gateway-stt/src/take.rs | 53 ++ crates/gateway-stt/src/take/interim.rs | 17 +- crates/gateway-stt/src/take/state.rs | 47 ++ crates/gateway-stt/src/take/window.rs | 191 ++++++ crates/gateway/Cargo.toml | 1 + crates/gateway/tests/it/realtime_stt.rs | 609 ++++++++++++++---- vibe/2026-09-05-2-generic-realtime-stt.md | 37 +- vibe/archdoc-next.md | 4 +- 16 files changed, 1015 insertions(+), 205 deletions(-) create mode 100644 crates/gateway-stt/src/take/window.rs diff --git a/Cargo.lock b/Cargo.lock index 17a48cf6..a4fad623 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1885,6 +1885,7 @@ dependencies = [ "gateway-routing", "gateway-stt", "gateway-web-search", + "hound", "ksni", "nvml-wrapper", "objc2", diff --git a/crates/gateway-stt/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index 351c2bde..662f9d53 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -25,15 +25,15 @@ destination = "removal after the Realtime route and Workshop relay replace the l "realtime/query.rs" = 70 "realtime/registry.rs" = 234 "realtime/result_mailbox.rs" = 232 -"realtime/route.rs" = 406 -"realtime/session.rs" = 440 -"realtime/session/items.rs" = 154 -"realtime/session/route.rs" = 142 -"realtime/session/state.rs" = 94 +"realtime/route.rs" = 440 +"realtime/session.rs" = 481 +"realtime/session/items.rs" = 155 +"realtime/session/route.rs" = 205 +"realtime/session/state.rs" = 109 "realtime/wire.rs" = 24 "realtime/wire/client.rs" = 363 "realtime/wire/server.rs" = 390 -"realtime/wire/server/events.rs" = 179 +"realtime/wire/server/events.rs" = 180 "realtime/wire/shared.rs" = 255 "realtime/wire/tests.rs" = 278 "replacement.rs" = 473 @@ -41,12 +41,13 @@ destination = "removal after the Realtime route and Workshop relay replace the l "service.rs" = 134 "status.rs" = 54 "stt.rs" = 725 -"take.rs" = 417 +"take.rs" = 470 "take/agreement.rs" = 62 "take/finalization.rs" = 177 -"take/interim.rs" = 145 -"take/state.rs" = 82 +"take/interim.rs" = 132 +"take/state.rs" = 129 "take/text.rs" = 9 +"take/window.rs" = 191 "test_fixtures.rs" = 444 "test_fixtures/generation.rs" = 100 "test_fixtures/native.rs" = 42 diff --git a/crates/gateway-stt/src/realtime/route.rs b/crates/gateway-stt/src/realtime/route.rs index 9e0aaed1..7a877f18 100644 --- a/crates/gateway-stt/src/realtime/route.rs +++ b/crates/gateway-stt/src/realtime/route.rs @@ -144,6 +144,11 @@ async fn run_socket( } 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; @@ -155,6 +160,28 @@ async fn run_socket( 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; }; @@ -162,6 +189,11 @@ async fn run_socket( return; } } + _ = interims.tick() => { + if session.schedule_interim().is_err() { + return; + } + } incoming = socket.next() => { let Some(Ok(message)) = incoming else { return; @@ -218,15 +250,14 @@ async fn handle_text( .update_text(text) .map(|()| vec![session.updated_event()]), ClientEvent::Append { audio, .. } => append_events(session, &audio, policy) - .await .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).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, @@ -234,7 +265,7 @@ async fn handle_text( } } -async fn append_events( +fn append_events( session: &mut Session, audio: &str, policy: &RoutePolicy, @@ -244,16 +275,19 @@ async fn append_events( if let Some(failure) = policy.precommit_failure() { session.record_pending_failure(failure.to_owned())?; } - session - .decode_interim() - .await - .map(|event| event.into_iter().collect()) + Ok(Vec::new()) } -fn commit_events(session: &mut Session) -> Result, SessionError> { +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 = Vec::from(session.committed_events(&receipt)); + 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) diff --git a/crates/gateway-stt/src/realtime/session.rs b/crates/gateway-stt/src/realtime/session.rs index 4e887d61..4acffc45 100644 --- a/crates/gateway-stt/src/realtime/session.rs +++ b/crates/gateway-stt/src/realtime/session.rs @@ -10,6 +10,7 @@ mod items; mod route; mod state; +use state::InterimTaskOutput; #[cfg(test)] use state::MAX_COMMITTED_ITEMS_PER_SESSION; use state::SESSION_CANCEL_JOIN_CAPACITY; @@ -66,6 +67,7 @@ impl Session { 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; @@ -98,7 +100,9 @@ impl Session { self.canceled_tasks.push(previous); } let epoch = self.begin_interim()?; - self.interim_task = Some(tokio::spawn(async move { (epoch, task.await) })); + self.interim_task = Some(tokio::spawn(async move { + InterimTaskOutput::Fixture(epoch, task.await) + })); Ok(epoch) } @@ -124,8 +128,18 @@ impl Session { }; let result = task.await; self.interim_task = None; - let (epoch, transcript) = result.map_err(|_| SessionError::CanceledTaskFailed)?; - Ok(self.accept_interim(epoch, transcript)) + match result.map_err(|_| SessionError::CanceledTaskFailed)? { + 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) } pub(crate) const fn canceled_join_count(&self) -> usize { @@ -161,10 +175,37 @@ impl Session { while let Some(task) = self.canceled_tasks.first_mut() { let result = task.await; self.canceled_tasks.remove(0); - if result.is_err_and(|error| !error.is_cancelled()) { - self.canceled_task_failed = true; - } + 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) diff --git a/crates/gateway-stt/src/realtime/session/items.rs b/crates/gateway-stt/src/realtime/session/items.rs index d85d176a..65ca5066 100644 --- a/crates/gateway-stt/src/realtime/session/items.rs +++ b/crates/gateway-stt/src/realtime/session/items.rs @@ -26,6 +26,7 @@ impl Session { task.abort(); self.canceled_tasks.push(task); } + self.last_interim_window = None; let Some(input) = self.input.take() else { return Err(SessionError::NoInput); }; diff --git a/crates/gateway-stt/src/realtime/session/route.rs b/crates/gateway-stt/src/realtime/session/route.rs index 65ee046c..643f1a5c 100644 --- a/crates/gateway-stt/src/realtime/session/route.rs +++ b/crates/gateway-stt/src/realtime/session/route.rs @@ -1,9 +1,8 @@ -use std::time::Duration; - -use gateway_stt_engine::{DecodeMode, DecodeRequest}; +use gateway_stt_engine::{DecodeMode, DecodeRequest, EnginePolicy}; use super::{Session, SessionError}; use crate::realtime::result_mailbox::{ItemResult, SESSION_RESULT_CAPACITY}; +use crate::realtime::session::state::InterimTaskOutput; use crate::realtime::wire::ServerEvent; impl Session { @@ -34,36 +33,95 @@ impl Session { Ok(()) } - pub(crate) async fn decode_interim(&mut self) -> Result, SessionError> { - let input = self.input.as_ref().ok_or(SessionError::NoInput)?; - let include_hypothesis = input.snapshot().include_hypothesis(); + 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 transcript = engine - .decode(DecodeRequest::new( - DecodeMode::Interim, - input.take().uncommitted_snapshot(engine.window_samples()), - input.take().guidance().to_vec(), - input.take().finalized(), - )) - .await - .map_err(|_| SessionError::Inference)?; + 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> { + let InterimTaskOutput::Decode { + epoch, + item_id, + segment_start, + audio_start, + audio_end, + transcript, + } = output + else { + unreachable!("fixture interims are accepted by the fixture path"); + }; + 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 update = input.take().next_interim_snapshot(&transcript); + 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(); - let delta = committed - .strip_prefix(&self.standard_interim_committed) - .ok_or(SessionError::Inference)?; - if !delta.is_empty() { - self.pending_interim.push(delta.to_owned()); + 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); } - committed.clone_into(&mut self.standard_interim_committed); } return Ok(None); } @@ -79,8 +137,8 @@ impl Session { input.item_id().to_owned(), self.hypothesis_revision, snapshot, - u64::try_from(Duration::from_secs_f64(input.buffered_duration_seconds()).as_millis()) - .unwrap_or(u64::MAX), + sample_millis(audio_start), + sample_millis(audio_end), ))) } @@ -140,3 +198,8 @@ impl Session { 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 index 1d7cda06..97866f9d 100644 --- a/crates/gateway-stt/src/realtime/session/state.rs +++ b/crates/gateway-stt/src/realtime/session/state.rs @@ -12,11 +12,24 @@ use crate::realtime::wire::{EffectiveSession, IdGenerator}; pub(super) const SESSION_CANCEL_JOIN_CAPACITY: usize = 8; pub(super) const MAX_COMMITTED_ITEMS_PER_SESSION: usize = 4; -pub(super) type InterimTask = JoinHandle<(InterimEpoch, String)>; +pub(super) type InterimTask = JoinHandle; #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) struct InterimEpoch(pub(super) u64); +#[derive(Debug)] +pub(super) enum InterimTaskOutput { + 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)] @@ -55,6 +68,7 @@ pub(crate) struct Session { 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, @@ -81,6 +95,7 @@ impl Session { 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), diff --git a/crates/gateway-stt/src/realtime/wire/server/events.rs b/crates/gateway-stt/src/realtime/wire/server/events.rs index e3c4dd84..1825045b 100644 --- a/crates/gateway-stt/src/realtime/wire/server/events.rs +++ b/crates/gateway-stt/src/realtime/wire/server/events.rs @@ -29,6 +29,7 @@ impl ServerEvent { item_id: String, revision: u64, snapshot: InterimSnapshot, + audio_start_ms: u64, audio_end_ms: u64, ) -> Self { let (transcript, finalized, agreed, tentative) = snapshot.into_parts(); @@ -41,7 +42,7 @@ impl ServerEvent { finalized, agreed, tentative, - audio_start_ms: 0, + audio_start_ms, audio_end_ms, } } diff --git a/crates/gateway-stt/src/take.rs b/crates/gateway-stt/src/take.rs index d0fab240..457c8e2d 100644 --- a/crates/gateway-stt/src/take.rs +++ b/crates/gateway-stt/src/take.rs @@ -1,6 +1,7 @@ //! Per-take speech state and finalization ownership. use std::sync::Arc; +use std::sync::Mutex; #[cfg(test)] use gateway_stt_engine::TranscribeError; @@ -12,6 +13,7 @@ mod finalization; mod interim; mod state; mod text; +mod window; #[cfg(test)] use agreement::LocalAgreement; @@ -21,16 +23,26 @@ use finalization::{FinalPipeline, spawn_final_pipeline}; pub(crate) use interim::InterimSnapshot; use state::TakeState; use text::append_transcript; +use window::WholeWindowState; 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, } @@ -44,6 +56,7 @@ impl Take { Self { guidance, state, + whole_window: Mutex::new(WholeWindowState::default()), final_pipeline, } } @@ -78,6 +91,19 @@ impl Take { 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 fallback_snapshot(&self, window_samples: usize) -> Vec { let finalized = self.state.finalized_samples(); let buffer = TakeState::lock(&self.state.buffer); @@ -96,6 +122,33 @@ impl Take { self.state.finalized() } + pub(crate) fn next_interim(&self, hypothesis: &str) -> Option<(String, String)> { + self.next_interim_snapshot(hypothesis) + .map(InterimSnapshot::into_legacy_parts) + } + + pub(crate) fn next_interim_snapshot(&self, hypothesis: &str) -> Option { + TakeState::lock(&self.state.interim).next(&self.finalized(), hypothesis) + } + + 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, + ) + } + pub(crate) fn fallback_transcript(&self, tail: &str) -> String { let mut transcript = self.finalized(); append_transcript(&mut transcript, tail); diff --git a/crates/gateway-stt/src/take/interim.rs b/crates/gateway-stt/src/take/interim.rs index 3171dc68..57f71315 100644 --- a/crates/gateway-stt/src/take/interim.rs +++ b/crates/gateway-stt/src/take/interim.rs @@ -1,6 +1,5 @@ use super::agreement::{LocalAgreement, matching_token_prefix_end, token_spans}; use super::text::append_transcript; -use super::{Take, TakeState}; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct InterimSnapshot { @@ -11,7 +10,7 @@ pub(crate) struct InterimSnapshot { } impl InterimSnapshot { - fn new(finalized: String, agreed: String, tentative: String) -> Self { + pub(super) fn new(finalized: String, agreed: String, tentative: String) -> Self { let transcript = format!("{finalized}{agreed}{tentative}"); Self { transcript, @@ -86,18 +85,6 @@ impl InterimState { } } -impl Take { - pub(crate) fn next_interim(&self, hypothesis: &str) -> Option<(String, String)> { - self.next_interim_snapshot(hypothesis) - .map(InterimSnapshot::into_legacy_parts) - } - - pub(crate) fn next_interim_snapshot(&self, hypothesis: &str) -> Option { - let finalized = self.finalized(); - TakeState::lock(&self.state.interim).next(&finalized, hypothesis) - } -} - fn after_token_prefix(text: &str, tokens: usize) -> &str { if tokens == 0 { return text; @@ -119,7 +106,7 @@ fn owned_piece(has_prefix: bool, piece: &str) -> String { mod tests { use gateway_stt_engine::TranscribeError; - use super::Take; + use crate::take::Take; #[test] fn snapshot_fields_own_disjoint_exact_text_after_divergent_finalization() { diff --git a/crates/gateway-stt/src/take/state.rs b/crates/gateway-stt/src/take/state.rs index 67467de4..cccc24e7 100644 --- a/crates/gateway-stt/src/take/state.rs +++ b/crates/gateway-stt/src/take/state.rs @@ -30,6 +30,16 @@ impl TakeState { 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) + } + pub(super) fn record_finalized( &self, result: Result, @@ -80,3 +90,40 @@ impl TakeState { } } } + +#[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/window.rs b/crates/gateway-stt/src/take/window.rs new file mode 100644 index 00000000..1012adbc --- /dev/null +++ b/crates/gateway-stt/src/take/window.rs @@ -0,0 +1,191 @@ +use super::agreement::{matching_token_prefix_end, token_spans}; +use super::interim::InterimSnapshot; +use super::text::append_transcript; + +#[derive(Debug)] +struct PendingRegion { + end: usize, + text: String, +} + +#[derive(Debug, Default)] +pub(super) struct WholeWindowState { + segment_start: usize, + window_start: Option, + active: String, + 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, + text: 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.window_start = Some(window_start); + + let mut agreed = String::new(); + for region in &self.pending { + append_transcript(&mut agreed, ®ion.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) + } +} + +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" + ); + } +} diff --git a/crates/gateway/Cargo.toml b/crates/gateway/Cargo.toml index dfc9aa3a..193939c9 100644 --- a/crates/gateway/Cargo.toml +++ b/crates/gateway/Cargo.toml @@ -135,6 +135,7 @@ 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. diff --git a/crates/gateway/tests/it/realtime_stt.rs b/crates/gateway/tests/it/realtime_stt.rs index 2de77489..979f5b1c 100644 --- a/crates/gateway/tests/it/realtime_stt.rs +++ b/crates/gateway/tests/it/realtime_stt.rs @@ -1,6 +1,7 @@ //! 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; @@ -34,13 +35,22 @@ fn config(strict: bool) -> Config { } 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, 15, 500).expect("scripted speech starts") + scripted_service(factory, window_seconds, interval_ms).expect("scripted speech starts") } async fn server(strict: bool, service: &SpeechService) -> TestServer { @@ -126,7 +136,11 @@ async fn rejected_request(request: tokio_tungstenite::tungstenite::http::Request } async fn receive(socket: &mut Socket) -> serde_json::Value { - let message = tokio::time::timeout(PHASE_TIMEOUT, socket.next()) + 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") @@ -158,8 +172,7 @@ async fn append_audio(socket: &mut Socket, audio: String) { } fn audio() -> String { - let bytes = vec![0_u8; 24_000 * 2 / 10]; - base64::engine::general_purpose::STANDARD.encode(bytes) + audio_samples(&vec![8_192; 2_400]) } fn audio_samples(samples: &[i16]) -> String { @@ -170,10 +183,69 @@ fn audio_samples(samples: &[i16]) -> String { 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]); - audio_samples(&samples) +fn native_fixture(variable: &str, name: &str) -> PathBuf { + std::env::var_os(variable).map_or_else( + || { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../local/stt-fixtures") + .join(name) + }, + PathBuf::from, + ) +} + +fn native_jfk_24khz() -> Vec { + let path = native_fixture("PROMPTFORGE_WHISPER_AUDIO", "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 = native_fixture("PROMPTFORGE_WHISPER_MODEL", "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 { @@ -258,6 +330,144 @@ async fn expect_error( event } +#[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.park_next(); + append_audio(&mut socket, audio()).await; + let parked = interim.clone(); + assert!( + tokio::task::spawn_blocking(move || parked.wait_until_parked(PHASE_TIMEOUT)) + .await + .expect("park observer joins"), + "the first eligible scheduled decode parks" + ); + for _ in 0..5 { + append_audio(&mut 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" + ); + + interim.release(); + 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.park_next(); + for _ in 0..5 { + append_audio(&mut socket, audio()).await; + } + let canceled = interim.clone(); + assert!( + tokio::task::spawn_blocking(move || canceled.wait_until_parked(PHASE_TIMEOUT)) + .await + .expect("cancellation park observer joins") + ); + send( + &mut socket, + serde_json::json!({"type": "input_audio_buffer.clear"}), + ) + .await; + expect_type(&mut socket, "input_audio_buffer.cleared").await; + interim.release(); + 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; +} + +#[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.park_next(); + for _ in 0..5 { + append_audio(&mut socket, audio()).await; + } + let parked = interim.clone(); + assert!( + tokio::task::spawn_blocking(move || { + parked.wait_for_requests(request_count, PHASE_TIMEOUT) + && parked.wait_until_parked(PHASE_TIMEOUT) + }) + .await + .expect("park observer joins"), + "scheduled interim {request_count} reaches its worker" + ); + send( + &mut socket, + serde_json::json!({"type": "input_audio_buffer.clear"}), + ) + .await; + assert_eq!( + expect_type(&mut socket, "input_audio_buffer.cleared").await["type"], + "input_audio_buffer.cleared", + "completed canceled joins free bounded capacity before cycle {request_count}" + ); + interim.release(); + 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 canonical_fixture_drives_hypothesis_completion_and_clear() { let fixtures = canonical_sequences(); @@ -282,25 +492,27 @@ async fn canonical_fixture_drives_hypothesis_completion_and_clear() { .await; expect_type(&mut socket, "session.updated").await; + let mut hypotheses = Vec::new(); for _ in 0..2 { - let mut append = canonical_client( - &fixtures, - "hypothesis_negotiation", - "input_audio_buffer.append", + 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, ); - append["audio"] = serde_json::json!(audio()); - send(&mut socket, append).await; } - let first = expect_type( - &mut socket, - "conversation.item.input_audio_transcription.hypothesis", - ) - .await; - let second = 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); @@ -361,105 +573,262 @@ async fn canonical_fixture_drives_hypothesis_completion_and_clear() { #[tokio::test] async fn producer_snapshots_partition_finalized_agreed_and_tentative_text() { - let fixtures = canonical_sequences(); let interim = ScriptedDecoder::new(); for transcript in [ - "ask not your country", - "ask not your country", - "new tail first", - "new tail second", + "Why is it", + "Why is it", + "Why is this", + "is this working now", ] { interim.push_text(transcript); } let final_decoder = ScriptedDecoder::new(); - final_decoder.park_next(); - final_decoder.push_text("ask not your kingdom"); - final_decoder.push_text("second final"); - let service = speech(&interim, Some(&final_decoder)); + 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, - canonical_client(&fixtures, "producer_hypothesis_ownership", "session.update"), + serde_json::json!({ + "type": "session.update", + "session": { + "type": "transcription", + "include": ["item.input_audio_transcription.hypothesis"] + } + }), ) .await; expect_type(&mut socket, "session.updated").await; - append_audio(&mut socket, closed_segment()).await; - expect_type( + 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] +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, - "conversation.item.input_audio_transcription.hypothesis", + serde_json::json!({ + "type": "session.update", + "session": { + "type": "transcription", + "include": ["item.input_audio_transcription.hypothesis"] + } + }), ) .await; - append_audio(&mut socket, audio()).await; - expect_type( + 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"); - assert!(final_decoder.wait_until_parked(PHASE_TIMEOUT)); - interim.park_next(); - final_decoder.release(); - let completed_final = final_decoder.clone(); - assert!( - tokio::task::spawn_blocking(move || completed_final.wait_for_completed(1, PHASE_TIMEOUT)) - .await - .expect("final completion observer joins") - ); final_decoder.park_next(); - append_audio(&mut socket, closed_segment()).await; - let parked_interim = interim.clone(); + append_audio( + &mut socket, + audio_samples(&[vec![0; 72_000], vec![8_192; 12_000]].concat()), + ) + .await; + let parked = final_decoder.clone(); assert!( - tokio::task::spawn_blocking(move || parked_interim.wait_until_parked(PHASE_TIMEOUT)) + tokio::task::spawn_blocking(move || parked.wait_until_parked(PHASE_TIMEOUT)) .await - .expect("interim park observer joins") - ); - assert!( - final_decoder.wait_for_requests(2, PHASE_TIMEOUT), - "the first closed segment is finalized before snapshot assembly" + .expect("finalization park observer joins") ); - interim.release(); - - let third = expect_type( + let pending = expect_type( &mut socket, "conversation.item.input_audio_transcription.hypothesis", ) .await; + assert_eq!(pending["transcript"], "first phrase second phrase"); + assert_eq!(pending["finalized"], ""); + + final_decoder.release(); + 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 fourth = expect_type( + 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"); - for (actual, occurrence) in [(third, 0), (fourth, 1)] { - let expected = canonical_message( - &fixtures, - "producer_hypothesis_ownership", - "server", - "conversation.item.input_audio_transcription.hypothesis", - occurrence, + 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 path = native_fixture(variable, name); + assert!( + path.is_file(), + "native test fixture is missing: {}", + path.display() ); - for field in [ - "revision", - "transcript", - "finalized", - "agreed", - "tentative", - "audio_start_ms", - "audio_end_ms", - ] { - assert_eq!(actual[field], expected[field], "{field}: {actual}"); - } } + 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); - final_decoder.release(); 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"); +} + +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() } #[tokio::test] @@ -618,24 +987,19 @@ async fn mounted_route_drives_scripted_wire_ownership_errors_and_privacy() { "errors never echo buffered audio" ); - send( - &mut socket, - serde_json::json!({ - "type": "input_audio_buffer.append", - "event_id": "append-one", - "audio": audio() - }), - ) - .await; - send( - &mut socket, - serde_json::json!({ - "type": "input_audio_buffer.append", - "event_id": "append-two", - "audio": audio() - }), - ) - .await; + 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!({ @@ -857,24 +1221,18 @@ async fn mounted_session_errors_keep_canonical_codes_parameters_and_correlation( ) .await; - send( - &mut socket, - serde_json::json!({ - "type": "input_audio_buffer.append", - "event_id": "inference", - "audio": audio() - }), - ) - .await; - expect_error( - &mut socket, - "server_error", - "internal_error", - "Transcription failed", - serde_json::Value::Null, - "inference", - ) - .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, @@ -927,15 +1285,18 @@ async fn standard_interims_emit_only_appendable_agreed_deltas() { let mut socket = connect(server.addr, Some("test-token"), None, None).await; expect_type(&mut socket, "session.created").await; - for _ in 0..3 { - send( - &mut socket, - serde_json::json!({ - "type": "input_audio_buffer.append", - "audio": audio() - }), - ) - .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, diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 20a3bc9a..a8f4c664 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -74,7 +74,7 @@ isProject: false - 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 39 are the sole logging exception. + - 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 40 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. @@ -371,7 +371,7 @@ isProject: false 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 36, adds only `workshop-server -> shared-loopback`. Final Phase C, Step 37 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. +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 37, adds only `workshop-server -> shared-loopback`. Final Phase C, Step 38 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] @@ -752,7 +752,20 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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: Pass installed Windows microphone acceptance +### 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: Pass installed Windows microphone acceptance - 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. @@ -763,9 +776,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 35; the operator must exercise the installed application and record passing evidence, which alone gates legacy removal. +- Consumes and gates: consumes Steps 30 through 36; the operator must exercise the installed application and record passing evidence, which alone gates legacy removal. -### Step 37: Remove legacy seams and tests +### Step 38: Remove legacy seams and tests - 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. @@ -779,7 +792,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 38: Finalize architecture and documentation +### Step 39: Finalize architecture and documentation - 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. @@ -789,9 +802,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 37 final topology; final verification starts only with zero temporary exceptions. +- Consumes and gates: consumes Step 38 final topology; final verification starts only with zero temporary exceptions. -### Step 39: Bookend Gateway serving logs +### Step 40: Bookend Gateway serving logs - 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. @@ -799,9 +812,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 38 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 40's full release verification must pass after this change. +- Consumes and gates: this operator-requested observability correction is independent of STT and follows Step 39 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 41's full release verification must pass after this change. -### Step 40: Run every release gate and repeat acceptance +### Step 41: Run every release gate and repeat acceptance - Artifacts: append command results, hashes, ratchet counts, native equivalence, generated-doc cleanliness, and repeated installed-microphone evidence to `design/generic-realtime-stt-acceptance.md`; change no implementation. - Scope: run every exit criterion independently under PowerShell 5.1 and stop on any failure. @@ -835,6 +848,6 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 39, then repeats the Step 36 installed-package microphone scenarios. Completion requires every command, ratchet, generated-doc check, and physical scenario to pass with no open finding. +- Consumes and gates: consumes Step 40, then repeats the Step 37 installed-package microphone scenarios. 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 39's exact two records, or completed Workshop baseline-ratchet work. VAD tuning, alignment, 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 +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 40'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/archdoc-next.md b/vibe/archdoc-next.md index d607ab62..5add1d9b 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -131,7 +131,7 @@ N2 | observation | Violates A96 @ crates/workshop-server/ui/test/realtime-wire-f 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 +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 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 @@ -155,7 +155,7 @@ N26 | observation | constructor-injection @ crates/gateway-stt/src/runtime.rs::S 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 +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 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 From 2d1ecca839634034d5b70901229d9012e74a18a0 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 04:19:38 -0700 Subject: [PATCH 46/86] Reconcile explicitly skipped final ranges Preserve accepted hypothesis text only when explicit skip outcomes continuously cover its committed audio range. Keep every decoded final authoritative, including empty text, and preserve first-failure fallback order while final work interleaves with later input. - `SegmentOutcome`, `FinalRangeOutcome`, and `AcceptedHypothesis` bind each decision and accepted text to exact sample coverage. Skip reasons distinguish short speech, undersized final windows, and silence from decoded empty text. - `assemble_completion` appends decoded results in order and uses an accepted hypothesis once only when contiguous skipped outcomes cover its committed range without a decoded interruption or gap. - `run_final_pipeline` records leading silence and range outcomes in command order, stops later decode work after the first failure, and returns that failure before fallback transcription. - `assert_stop_reconciles_skipped_range` parks earlier final work while later speech and silence commit, then verifies one final decode and recovered skipped-range text. `assert_same_range_final_authority` pins divergent and empty decoded finals, while `skipped_then_decoded_then_failed_falls_back_once_in_audio_order` pins failure ordering. - `module-ceilings.toml` raises the ratchets for the expanded range-tracking modules and registers the new outcome module. Design: new value-object @ crates/gateway-stt/src/segment.rs::SegmentOutcome Design: new value-object @ crates/gateway-stt/src/take/final_outcome.rs::SkipReason Design: new value-object @ crates/gateway-stt/src/take/final_outcome.rs::FinalRangeResult Design: new value-object @ crates/gateway-stt/src/take/final_outcome.rs::FinalRangeOutcome Design: new oversized-unit @ crates/gateway-stt/src/take/final_outcome.rs Design: new pure-function @ crates/gateway-stt/src/take/final_outcome.rs::assemble_completion deps: &[AcceptedHypothesis],&[FinalRangeOutcome],usize Design: new pure-function @ crates/gateway-stt/src/take/final_outcome.rs::skipped_outcomes_exactly_cover deps: &Range,&[FinalRangeOutcome],usize Design: extends message-passing @ crates/gateway-stt/src/take/finalization.rs::FinalPipeline Design: extends oversized-unit @ crates/gateway-stt/src/take/finalization.rs Design: extends shared-mutable-state @ crates/gateway-stt/src/take/state.rs::TakeState Design: extends oversized-unit @ crates/gateway-stt/src/take/state.rs Design: new value-object @ crates/gateway-stt/src/take/window.rs::AcceptedHypothesis Design: extends oversized-unit @ crates/gateway-stt/src/take/window.rs Design: extends oversized-unit @ crates/gateway-stt/tests/it/legacy_stream.rs Design: extends oversized-unit @ crates/gateway-stt/tests/it/realtime_session.rs Design: extends oversized-unit @ crates/gateway/tests/it/realtime_stt.rs Design: new oversized-unit @ crates/gateway/tests/it/realtime_stt.rs::assert_stop_reconciles_skipped_range Violates: A2 - credential ownership in crates/gateway-stt/src/take is not determinable from diff Pending: N8 - compounds Deferred: pure silence without an accepted hypothesis has no new assertion Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- crates/gateway-stt/module-ceilings.toml | 11 +- crates/gateway-stt/src/segment.rs | 34 ++- crates/gateway-stt/src/take.rs | 44 +-- crates/gateway-stt/src/take/final_outcome.rs | 98 +++++++ crates/gateway-stt/src/take/finalization.rs | 260 ++++++++++++++++-- crates/gateway-stt/src/take/state.rs | 38 ++- crates/gateway-stt/src/take/window.rs | 67 ++++- .../gateway-stt/src/test_fixtures/segment.rs | 6 +- crates/gateway-stt/tests/it/legacy_stream.rs | 55 +++- .../gateway-stt/tests/it/realtime_session.rs | 14 +- crates/gateway/tests/it/realtime_stt.rs | 191 ++++++++++++- vibe/2026-09-05-2-generic-realtime-stt.md | 37 ++- vibe/archdoc-next.md | 3 +- 13 files changed, 757 insertions(+), 101 deletions(-) create mode 100644 crates/gateway-stt/src/take/final_outcome.rs diff --git a/crates/gateway-stt/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index 662f9d53..90add480 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -37,18 +37,19 @@ destination = "removal after the Realtime route and Workshop relay replace the l "realtime/wire/shared.rs" = 255 "realtime/wire/tests.rs" = 278 "replacement.rs" = 473 -"segment.rs" = 239 +"segment.rs" = 253 "service.rs" = 134 "status.rs" = 54 "stt.rs" = 725 "take.rs" = 470 "take/agreement.rs" = 62 -"take/finalization.rs" = 177 +"take/final_outcome.rs" = 98 +"take/finalization.rs" = 379 "take/interim.rs" = 132 -"take/state.rs" = 129 +"take/state.rs" = 163 "take/text.rs" = 9 -"take/window.rs" = 191 +"take/window.rs" = 250 "test_fixtures.rs" = 444 "test_fixtures/generation.rs" = 100 "test_fixtures/native.rs" = 42 -"test_fixtures/segment.rs" = 12 +"test_fixtures/segment.rs" = 14 diff --git a/crates/gateway-stt/src/segment.rs b/crates/gateway-stt/src/segment.rs index 7293849b..6cc35d5e 100644 --- a/crates/gateway-stt/src/segment.rs +++ b/crates/gateway-stt/src/segment.rs @@ -13,6 +13,12 @@ use std::ops::Range; 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 = EnginePolicy::SAMPLE_RATE * 30 / 1000; @@ -67,7 +73,7 @@ impl Segmenter { /// 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(crate) 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 = EnginePolicy::is_silence(frame); @@ -81,10 +87,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) => { @@ -123,8 +128,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 } @@ -189,9 +196,13 @@ mod tests { 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, @@ -218,7 +229,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/take.rs b/crates/gateway-stt/src/take.rs index 457c8e2d..e3a1e277 100644 --- a/crates/gateway-stt/src/take.rs +++ b/crates/gateway-stt/src/take.rs @@ -9,6 +9,7 @@ use gateway_stt_engine::TranscribeError; use crate::generation::GenerationLease; mod agreement; +mod final_outcome; mod finalization; mod interim; mod state; @@ -184,8 +185,11 @@ impl Take { 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(); - Some(pipeline.finalization(tail)) + drop(buffer); + let accepted = TakeState::lock(&self.whole_window).accepted_hypotheses(committed_samples); + Some(pipeline.finalization(tail, consumed, committed_samples, accepted)) } pub(crate) async fn complete(&self) -> Option> { @@ -321,33 +325,20 @@ mod tests { take.record_finalized(Ok("successful segment".to_owned())); take.record_failure("tail failed"); - assert_eq!(take.state.completion(), Err("tail failed".to_owned())); + assert_eq!(take.state.completion(&[], 0), Err("tail failed".to_owned())); assert_eq!( take.fallback_transcript("fallback tail"), "successful segment fallback tail" ); } - #[test] - fn closed_segment_failure_does_not_duplicate_a_successful_tail_in_fallback() { - let take = Take::without_final(Vec::new()); - take.record_failure("closed segment failed"); - take.record_finalized(Ok("successful tail".to_owned())); - - assert_eq!( - take.state.completion(), - Err("closed segment failed".to_owned()) - ); - assert_eq!(take.fallback_transcript("fallback tail"), "fallback tail"); - } - #[tokio::test] async fn failed_segment_audio_remains_in_the_fallback_window() { let take = Take::without_final(Vec::new()); - let successful = vec![1.0; 4]; - let failed = vec![2.0; 3]; - let skipped = vec![3.0; 2]; - let tail = vec![4.0]; + let successful = vec![1.0; 8_000]; + let failed = vec![2.0; 8_000]; + let skipped = vec![3.0; 8_000]; + let tail = vec![4.0; 8_000]; take.append( &[ successful.clone(), @@ -380,21 +371,24 @@ mod tests { commands .send(FinalCommand::Segment { samples: successful, - end: 4, + range: 0..8_000, + leading_silence: None, }) .await .expect("the successful segment queues"); commands .send(FinalCommand::Segment { samples: failed.clone(), - end: 7, + range: 8_000..16_000, + leading_silence: None, }) .await .expect("the failed segment queues"); commands .send(FinalCommand::Segment { samples: skipped.clone(), - end: 9, + range: 16_000..24_000, + leading_silence: None, }) .await .expect("the skipped segment queues"); @@ -402,6 +396,9 @@ mod tests { commands .send(FinalCommand::Complete { tail: tail.clone(), + start: 24_000, + committed_samples: 32_000, + accepted: Vec::new(), reply, }) .await @@ -449,6 +446,9 @@ mod tests { commands .send(FinalCommand::Complete { tail: Vec::new(), + start: 0, + committed_samples: 0, + accepted: Vec::new(), reply, }) .await 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 index c72ceef6..540d8548 100644 --- a/crates/gateway-stt/src/take/finalization.rs +++ b/crates/gateway-stt/src/take/finalization.rs @@ -1,14 +1,18 @@ 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, TranscribeError}; +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; @@ -17,10 +21,19 @@ pub(super) const FINAL_SEGMENT_CAPACITY: usize = 4; pub(super) enum FinalCommand { Segment { samples: Vec, - end: usize, + 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>, }, } @@ -41,23 +54,38 @@ impl Drop for FinalPipeline { impl FinalPipeline { pub(super) fn submit_closed_segments(&self, state: &TakeState) { loop { - let segment = { + let outcome = { let buffer = TakeState::lock(&state.buffer); - TakeState::lock(&state.segmenter) - .poll(&buffer) - .map(|range| (buffer[range.clone()].to_vec(), range.end)) + 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((samples, end)) = segment else { + 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(FinalCommand::Segment { samples, end }) - { + match self.commands.try_send(command) { Ok(()) => {} Err(mpsc::error::TrySendError::Full(_)) => { self.pending_segments.fetch_sub(1, Ordering::AcqRel); @@ -78,12 +106,24 @@ impl FinalPipeline { self.pending_segments.load(Ordering::Acquire) } - pub(super) fn finalization(&self, tail: Vec) -> TakeFinalization { + 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, reply }) + .send(FinalCommand::Complete { + tail, + start, + committed_samples, + accepted, + reply, + }) .await .is_err() { @@ -153,25 +193,187 @@ pub(super) async fn run_final_pipeline( F: Future>>, { while let Some(command) = receiver.recv().await { - let (samples, finalized_samples, completion) = match command { - FinalCommand::Segment { samples, end } => (samples, Some(end), None), - FinalCommand::Complete { tail, reply } => (tail, None, Some(reply)), - }; - if !state.has_failure() { - let finalized = state.finalized(); - match decode(samples, guidance.to_vec(), finalized).await { - Some(result) => state.record_finalized(result, finalized_samples), - None => { - state.record_failure("final transcription worker is unavailable".to_owned()); - } + 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; } } - if finalized_samples.is_some() { - pending_segments.fetch_sub(1, Ordering::AcqRel); + } +} + +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)); } - if let Some(reply) = completion { - drop(reply.send(state.completion())); - break; + 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/state.rs b/crates/gateway-stt/src/take/state.rs index cccc24e7..f92d96b1 100644 --- a/crates/gateway-stt/src/take/state.rs +++ b/crates/gateway-stt/src/take/state.rs @@ -1,9 +1,12 @@ use std::sync::{Mutex, MutexGuard, PoisonError}; +#[cfg(test)] use gateway_stt_engine::TranscribeError; +use super::final_outcome::{FinalRangeOutcome, FinalRangeResult, assemble_completion}; use super::interim::InterimState; use super::text::append_transcript; +use super::window::AcceptedHypothesis; use crate::segment::Segmenter; #[derive(Debug, Default)] @@ -11,6 +14,8 @@ struct FinalizedState { text: String, failure: Option, samples: usize, + outcomes: Vec, + has_skipped_coverage: bool, } #[derive(Debug, Default)] @@ -40,6 +45,7 @@ impl TakeState { (state.text.clone(), state.samples) } + #[cfg(test)] pub(super) fn record_finalized( &self, result: Result, @@ -58,6 +64,25 @@ impl TakeState { } } + 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() { @@ -82,11 +107,20 @@ impl TakeState { Self::lock(&self.finalized).failure.take() } - pub(super) fn completion(&self) -> Result { + 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 => Ok(state.text.clone()), + 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) + } } } } diff --git a/crates/gateway-stt/src/take/window.rs b/crates/gateway-stt/src/take/window.rs index 1012adbc..492cb595 100644 --- a/crates/gateway-stt/src/take/window.rs +++ b/crates/gateway-stt/src/take/window.rs @@ -1,11 +1,33 @@ +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, - text: String, + accepted: AcceptedHypothesis, } #[derive(Debug, Default)] @@ -13,6 +35,7 @@ pub(super) struct WholeWindowState { segment_start: usize, window_start: Option, active: String, + active_end: usize, pending: Vec, last: Option, } @@ -24,7 +47,7 @@ impl WholeWindowState { finalized_samples: usize, segment_start: usize, window_start: usize, - _window_end: usize, + window_end: usize, hypothesis: &str, ) -> Option { self.pending.retain(|region| region.end > finalized_samples); @@ -32,7 +55,10 @@ impl WholeWindowState { if !self.active.is_empty() { self.pending.push(PendingRegion { end: segment_start, - text: std::mem::take(&mut self.active), + accepted: AcceptedHypothesis::new( + self.segment_start..self.active_end, + std::mem::take(&mut self.active), + ), }); } self.window_start = None; @@ -51,11 +77,12 @@ impl WholeWindowState { 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, ®ion.text); + append_transcript(&mut agreed, region.accepted.text()); } append_transcript(&mut agreed, self.active[..agreed_end].trim()); let agreed = owned_piece(!finalized.is_empty(), &agreed); @@ -70,6 +97,22 @@ impl WholeWindowState { 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 { @@ -188,4 +231,20 @@ mod tests { "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/segment.rs b/crates/gateway-stt/src/test_fixtures/segment.rs index f3f8bb2e..3cf2dd61 100644 --- a/crates/gateway-stt/src/test_fixtures/segment.rs +++ b/crates/gateway-stt/src/test_fixtures/segment.rs @@ -5,8 +5,10 @@ pub fn segment_ranges(samples: &[f32]) -> Vec> { let mut segmenter = crate::segment::Segmenter::new(); let mut ranges = Vec::new(); - while let Some(range) = segmenter.poll(samples) { - ranges.push(range); + 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/it/legacy_stream.rs b/crates/gateway-stt/tests/it/legacy_stream.rs index 66455637..a7e9c539 100644 --- a/crates/gateway-stt/tests/it/legacy_stream.rs +++ b/crates/gateway-stt/tests/it/legacy_stream.rs @@ -9,7 +9,9 @@ use std::time::Duration; use futures_util::{SinkExt as _, StreamExt as _}; -use gateway_stt::test_fixtures::segment_ranges; +use gateway_stt::test_fixtures::{ + ScriptedDecoder, ScriptedModelFactory, scripted_service, segment_ranges, +}; use gateway_stt_engine::EnginePolicy; use serde_json::json; use tokio_tungstenite::tungstenite; @@ -46,6 +48,57 @@ fn legacy_stream_policy_constants_stay_pinned() { ); } +#[tokio::test] +async fn skipped_then_decoded_then_failed_falls_back_once_in_audio_order() { + let interim = ScriptedDecoder::new(); + interim.push_text("fallback whole take"); + let final_decoder = ScriptedDecoder::new(); + final_decoder.push_text("later accurate segment"); + final_decoder.push_error("following segment failed"); + let service = scripted_service( + ScriptedModelFactory::new(interim.clone()).with_final(final_decoder.clone()), + 15, + 500, + ) + .expect("scripted speech starts"); + let server = TestServer::spawn_with(service); + let mut socket = JsonSocket::connect(&server.ws_url("/stt")).await; + socket.send_text("start").await; + assert_eq!(socket.recv_json().await["type"], "stream"); + + let silence = vec![0.0; EnginePolicy::SAMPLE_RATE * 3]; + let samples = [ + vec![0.5; EnginePolicy::SAMPLE_RATE / 10], + silence.clone(), + vec![0.5; EnginePolicy::SAMPLE_RATE], + silence.clone(), + vec![0.5; EnginePolicy::SAMPLE_RATE], + silence, + ] + .concat(); + send_samples_once(&mut socket, &samples).await; + let completed = final_decoder.clone(); + assert!( + tokio::task::spawn_blocking(move || { + completed.wait_for_completed(2, Duration::from_secs(2)) + }) + .await + .expect("completion observer joins"), + "the later success and following failure complete in order: {:?}", + final_decoder.requests() + ); + + socket.send_text("stop").await; + let reply = socket + .recv_until(Duration::from_secs(1), |frame| frame["type"] == "final") + .await; + assert_eq!(reply["text"], "fallback whole take"); + assert_eq!(interim.requests().len(), 1); + + socket.close().await; + server.shutdown().await; +} + fn transcript_words(text: &str) -> Vec { text.split_whitespace() .map(|word| { diff --git a/crates/gateway-stt/tests/it/realtime_session.rs b/crates/gateway-stt/tests/it/realtime_session.rs index 3a4eb086..8294905e 100644 --- a/crates/gateway-stt/tests/it/realtime_session.rs +++ b/crates/gateway-stt/tests/it/realtime_session.rs @@ -509,6 +509,16 @@ fn append_committable(session: &mut RealtimeSessionFixture) -> String { .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(); @@ -576,7 +586,7 @@ async fn four_items_finalize_in_reverse_order_without_crossing_ownership() { session .update_text(&update(&format!("prompt-{index}"), true)) .expect("item prompt updates"); - append_committable(&mut session); + append_decodable(&mut session); ids.push(session.commit().expect("item commits").item_id().to_owned()); } assert_eq!( @@ -637,7 +647,7 @@ async fn canceling_item_finish_keeps_finalization_owned_for_retry() { ScriptedModelFactory::new(interim).with_final(final_decoder.clone()), ) .expect("scripted session starts"); - append_committable(&mut session); + append_decodable(&mut session); let item = session.commit().expect("item commits"); let item_id = item.item_id().to_owned(); diff --git a/crates/gateway/tests/it/realtime_stt.rs b/crates/gateway/tests/it/realtime_stt.rs index 979f5b1c..90ac3a92 100644 --- a/crates/gateway/tests/it/realtime_stt.rs +++ b/crates/gateway/tests/it/realtime_stt.rs @@ -711,6 +711,168 @@ async fn consumed_boundary_rebases_before_delayed_finalization_completes() { server.shutdown().await; } +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"); + final_decoder.park_next(); + 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(); + append_audio(&mut socket, audio_samples(&before_stop)).await; + let hypothesis = expect_type( + &mut socket, + "conversation.item.input_audio_transcription.hypothesis", + ) + .await; + assert!( + hypothesis["transcript"] + .as_str() + .is_some_and(|text| text.ends_with("last word")) + ); + let parked = final_decoder.clone(); + assert!( + tokio::task::spawn_blocking(move || parked.wait_until_parked(PHASE_TIMEOUT)) + .await + .expect("finalization park observer joins"), + "the accepted hypothesis is captured while earlier final work is parked" + ); + + append_audio(&mut socket, audio_samples(&vec![0; 72_000])).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; + final_decoder.release(); + 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; +} + +#[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; +} + +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); + final_decoder.park_next(); + 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; + + 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; + let parked = final_decoder.clone(); + assert!( + tokio::task::spawn_blocking(move || parked.wait_until_parked(PHASE_TIMEOUT)) + .await + .expect("finalization park observer joins"), + "the exact accepted range reaches authoritative final decoding" + ); + final_decoder.release(); + 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; +} + +#[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; +} + #[tokio::test] #[ignore = "requires packaged whisper.dll, ggml-tiny.en.bin, and jfk.wav fixtures"] async fn realtime_stt_native_incremental() { @@ -1069,14 +1231,16 @@ async fn admission_is_bounded_and_replacement_closes_with_1012() { for mut socket in sockets.drain(1..) { socket.close(None).await.expect("socket closes"); } - send( - &mut sockets[0], - serde_json::json!({ - "type": "input_audio_buffer.append", - "audio": audio() - }), - ) - .await; + for _ in 0..5 { + send( + &mut sockets[0], + serde_json::json!({ + "type": "input_audio_buffer.append", + "audio": audio() + }), + ) + .await; + } send( &mut sockets[0], serde_json::json!({"type": "input_audio_buffer.commit"}), @@ -1407,7 +1571,9 @@ async fn commit_existing_item( append: &serde_json::Value, previous: Option<&String>, ) -> String { - send(socket, append.clone()).await; + for _ in 0..5 { + send(socket, append.clone()).await; + } send( socket, serde_json::json!({"type": "input_audio_buffer.commit"}), @@ -1509,11 +1675,12 @@ async fn expect_retried_item( #[tokio::test] async fn saturated_commit_preserves_the_canonical_input_for_retry() { let fixtures = canonical_sequences(); - let append = canonical_client( + 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", @@ -1563,7 +1730,9 @@ async fn saturated_commit_preserves_the_canonical_input_for_retry() { "the serial final worker is parked while four items own finalization" ); - send(&mut socket, append.clone()).await; + for _ in 0..5 { + send(&mut socket, append.clone()).await; + } send(&mut socket, commit).await; let saturated = expect_type(&mut socket, "error").await; let requests_at_saturation = final_decoder.requests().len(); diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index a8f4c664..896f4a1b 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -74,7 +74,7 @@ isProject: false - 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 40 are the sole logging exception. + - 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. @@ -371,7 +371,7 @@ isProject: false 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 37, adds only `workshop-server -> shared-loopback`. Final Phase C, Step 38 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. +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] @@ -765,7 +765,20 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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: Pass installed Windows microphone acceptance +### 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 - 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. @@ -776,9 +789,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 36; the operator must exercise the installed application and record passing evidence, which alone gates legacy removal. +- 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 38: Remove legacy seams and tests +### Step 39: Remove legacy seams and tests - 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. @@ -792,7 +805,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 39: Finalize architecture and documentation +### Step 40: Finalize architecture and documentation - 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. @@ -802,9 +815,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 38 final topology; final verification starts only with zero temporary exceptions. +- Consumes and gates: consumes Step 39 final topology; final verification starts only with zero temporary exceptions. -### Step 40: Bookend Gateway serving logs +### Step 41: Bookend Gateway serving logs - 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. @@ -812,9 +825,9 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 39 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 41's full release verification must pass after this change. +- 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 41: Run every release gate and repeat acceptance +### Step 42: Run every release gate and repeat acceptance - Artifacts: append command results, hashes, ratchet counts, native equivalence, generated-doc cleanliness, and repeated installed-microphone evidence to `design/generic-realtime-stt-acceptance.md`; change no implementation. - Scope: run every exit criterion independently under PowerShell 5.1 and stop on any failure. @@ -848,6 +861,6 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 40, then repeats the Step 37 installed-package microphone scenarios. Completion requires every command, ratchet, generated-doc check, and physical scenario to pass with no open finding. +- Consumes and gates: consumes Step 41, then repeats the Step 38 installed-package microphone scenarios. 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 40'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 +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/archdoc-next.md b/vibe/archdoc-next.md index 5add1d9b..a1686e1d 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -133,7 +133,7 @@ N4 | observation | shared-parameter-cluster @ crates/gateway-stt-engine/src/fina 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 +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 @@ -185,3 +185,4 @@ N56 | observation | shared-parameter-cluster @ crates/workshop-server/ui/src/ui/ 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 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 +N60 | observation | Violates A2 @ crates/gateway-stt/src/take: credential ownership is not determinable from diff | Reconcile explicitly skipped final ranges From 37664614134b07605c4df09f44fcd48551c0cc43 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 05:16:41 -0700 Subject: [PATCH 47/86] Record installed acceptance and repair UI fixture Record the authoritative operator acceptance of the unsigned Windows package built from the accepted implementation identity. Preserve earlier failed attempts as audit history and document installed executable identities without claiming an invalid Workshop hash equality. Keep production and packaging inputs unchanged while deferring signing to release CI. - `design/generic-realtime-stt-acceptance.md` records exact installed paths, versions, hashes, timestamps, process identities, and the explicit `Works correctly. Accepted.` verdict. - `producer_hypothesis_ownership` joins `validSequenceCases` and repairs the canonical UI fixture expectation for producer-owned hypotheses. - `design/generic-realtime-stt-acceptance.md` leaves checklist details beyond live transcription and short-utterance Stop unmeasured. Production and packaging inputs remain unchanged from `2d1ecca8`, and signing remains untested. Violates: A96 - not determinable from diff Pending: N2 - compounds Deferred: Signing remains untested until release CI Deferred: Checklist items beyond live transcription and short-utterance Stop remain unmeasured Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- .../ui/test/realtime-wire-fixtures.mjs | 1 + design/generic-realtime-stt-acceptance.md | 646 ++++++++++++++++++ vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- vibe/archdoc-next.md | 2 +- 4 files changed, 649 insertions(+), 2 deletions(-) create mode 100644 design/generic-realtime-stt-acceptance.md diff --git a/crates/workshop-server/ui/test/realtime-wire-fixtures.mjs b/crates/workshop-server/ui/test/realtime-wire-fixtures.mjs index 628be0a3..0c696407 100644 --- a/crates/workshop-server/ui/test/realtime-wire-fixtures.mjs +++ b/crates/workshop-server/ui/test/realtime-wire-fixtures.mjs @@ -58,6 +58,7 @@ const validSequenceCases = [ "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", diff --git a/design/generic-realtime-stt-acceptance.md b/design/generic-realtime-stt-acceptance.md new file mode 100644 index 00000000..4d2b5a74 --- /dev/null +++ b/design/generic-realtime-stt-acceptance.md @@ -0,0 +1,646 @@ +# Generic Realtime STT installed-package acceptance + +## Status + +Accepted by the operator for the installed unsigned package built from current HEAD `2d1ecca8`. The applicable Gateway hashes match, and the operator's authoritative verdict for this build is `Works correctly. Accepted.` Prior attempts remain below as history and do not supersede this verdict. + +- Acceptance gate: passed by authoritative operator verdict +- Current automated installed-package boundary: passed +- Current operator boundary: passed for the repaired live transcription and short-utterance Stop behavior recorded below +- Installed application: `C:\Users\Vinnie\AppData\Local\PromptForge\promptforge-workshop.exe` +- Current Workshop process ID: 68872 +- Current Gateway process ID: 91128 +- Signing: not tested +- Commit created: no + +## 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 originally requested observation detail. Unchecked items were not individually recorded and are not retroactively claimed as measured; the operator's later authoritative verdict for the identified installed build is the acceptance decision. + +- [ ] 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 latest 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 diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 896f4a1b..6325d361 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -778,7 +778,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index a1686e1d..0daf0643 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -127,7 +127,7 @@ - [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 +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 From 2b7537eae62a868ad0f0be543756b3be52069b62 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 05:46:27 -0700 Subject: [PATCH 48/86] Reduce input.rs test line count Repair the Workshop baseline ratchet while keeping the tests equivalent. Add inputs to RecordingObserver and use it for all observer mutex access. Shorten or remove helper comments. - The staged change stays inside mod tests. It does not add or remove test cases or expected values. --- crates/workshop-server/src/input.rs | 33 +++++++++++------------------ 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/crates/workshop-server/src/input.rs b/crates/workshop-server/src/input.rs index 0cb08e35..690aa7ca 100644 --- a/crates/workshop-server/src/input.rs +++ b/crates/workshop-server/src/input.rs @@ -465,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, @@ -483,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() { @@ -494,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 { @@ -754,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())); } } @@ -793,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" ); @@ -817,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" ); From 49001c6868b343309ce385450776d77b6f80be5e Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 05:46:36 -0700 Subject: [PATCH 49/86] Retire legacy speech seams Retire the custom speech socket, capability proxy, status channel, and browser fallback after canonical fixtures and independent Gateway, relay, and browser suites cover their behavior. Leave one authenticated Realtime transcription path beside batch transcription and remove the reverse Workshop dependency. Enforce the final graph, route surface, and zero-symbol state as permanent architecture gates. - `gateway-stt` now depends on configuration, artifact, backend, engine, and progress crates only. `gateway` owns route mounting, the Whisper backend depends on the engine and FFI leaf, and `workshop-server` remains an independent authenticated relay with no temporary dependency exceptions. - `SpeechService` merges only batch and Realtime routes. Gateway keeps `POST /v1/audio/transcriptions` and `WS /v1/realtime?intent=transcription`; Gateway and Workshop both return not found for `/stt` and `/stt/capability`, and Workshop fixes its upstream socket to Realtime. - `legacy_stream.rs` policy, generation, origin, interim, final, fallback, segmentation, silence, and disconnect assertions map to the canonical wire contract and the mounted scheduler, hypothesis, completion, authority, privacy, and typed-error cases in `realtime_stt.rs`. The removed `take.rs` agreement and fallback cases map to the same producer-partition, skipped-range, divergent-final, and terminal-failure evidence. - `routes/stt.rs` relay assertions map to the authenticated, same-origin, payload-opaque, control-frame, and close propagation cases in `realtime_relay.rs`. The boolean, malformed-body, and network cases in `stt-capability.mjs` map to removal of the probe, not-found route checks, boot-time unexpected-fetch rejection, and Realtime ready or unavailable cases in `agent-stt.mjs`; insertion, second-take, cleanup, and capture remain covered by `agent-stt-boot.mjs` and the sole `pcm16-capture` processor checks in `pcm-worklet.mjs`. - `legacy_speech_seams_are_absent_from_production_sources` rejects the old Rust modules, connectors, headers, route factories, browser types, capability probe, and processor name. Its companion zero-symbol test injects every forbidden UI form, checks adversarial processor contexts, and proves current Realtime and browser-capture symbols remain accepted. Design: removes surface-growth @ crates/gateway-stt/src/stt.rs boundary: wire Design: removes shared-mutable-state @ crates/gateway-stt/src/generation.rs::Shared::changes Design: removes flag-parameter @ crates/workshop-server/src/gateway/socket.rs::GatewayClient::connect_socket Design: removes surface-growth @ crates/workshop-server/src/routes/stt.rs boundary: wire Design: removes speculative-abstraction @ crates/workshop-server/src/serve.rs::RouteFactory Design: removes surface-growth @ crates/workshop-server/src/lib.rs::spawn_with_routes boundary: pub Design: removes surface-growth @ crates/workshop-server/ui/src/ui/stt.ts::sttCapability boundary: pub Violates: A2 - credential ownership in crates/gateway-stt/src/service.rs::SpeechService is not determinable from diff Pending: N36 - compounds Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- Cargo.lock | 1 - crates/gateway-stt/AGENTS.md | 4 +- crates/gateway-stt/Cargo.toml | 3 - crates/gateway-stt/module-ceilings.toml | 19 +- crates/gateway-stt/src/generation.rs | 22 +- crates/gateway-stt/src/lib.rs | 4 +- crates/gateway-stt/src/service.rs | 20 +- crates/gateway-stt/src/stt.rs | 725 ------------------ crates/gateway-stt/src/take.rs | 200 +---- crates/gateway-stt/src/take/agreement.rs | 32 - crates/gateway-stt/src/take/interim.rs | 105 --- crates/gateway-stt/src/take/state.rs | 6 - crates/gateway-stt/tests/common/mod.rs | 160 ---- crates/gateway-stt/tests/it/architecture.rs | 139 +++- crates/gateway-stt/tests/it/legacy_stream.rs | 600 --------------- crates/gateway-stt/tests/it/main.rs | 2 - crates/gateway-stt/tests/it/service.rs | 23 - crates/gateway/AGENTS.md | 2 +- crates/gateway/src/lib.rs | 35 +- crates/gateway/tests/it/realtime_stt.rs | 47 +- crates/workshop-server/AGENTS.md | 4 +- crates/workshop-server/module-ceilings.toml | 16 +- crates/workshop-server/src/app.rs | 4 +- crates/workshop-server/src/csp.rs | 4 +- crates/workshop-server/src/gateway.rs | 2 +- crates/workshop-server/src/gateway/socket.rs | 33 +- crates/workshop-server/src/lib.rs | 8 +- crates/workshop-server/src/routes.rs | 1 - crates/workshop-server/src/routes/stt.rs | 297 ------- crates/workshop-server/src/serve.rs | 59 +- crates/workshop-server/tests/common/mod.rs | 21 +- .../tests/it/realtime_relay.rs | 19 + crates/workshop-server/ui/pcm-worklet.js | 15 +- .../ui/src/services/protocol.ts | 42 +- crates/workshop-server/ui/src/ui/stt.ts | 378 +-------- .../ui/test/agent-stt-boot.mjs | 6 +- crates/workshop-server/ui/test/agent-stt.mjs | 20 +- .../workshop-server/ui/test/helpers/boot.mjs | 19 +- .../workshop-server/ui/test/pcm-worklet.mjs | 15 +- .../ui/test/stt-capability.mjs | 115 --- vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- vibe/archdoc-next.md | 4 +- 42 files changed, 262 insertions(+), 2971 deletions(-) delete mode 100644 crates/gateway-stt/src/stt.rs delete mode 100644 crates/gateway-stt/tests/it/legacy_stream.rs delete mode 100644 crates/workshop-server/src/routes/stt.rs delete mode 100644 crates/workshop-server/ui/test/stt-capability.mjs diff --git a/Cargo.lock b/Cargo.lock index a4fad623..a5458d76 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2018,7 +2018,6 @@ dependencies = [ "toml 0.8.2", "tower", "tracing", - "workshop-server", ] [[package]] diff --git a/crates/gateway-stt/AGENTS.md b/crates/gateway-stt/AGENTS.md index c670d6d7..71e100b6 100644 --- a/crates/gateway-stt/AGENTS.md +++ b/crates/gateway-stt/AGENTS.md @@ -2,6 +2,6 @@ This crate is the gateway speech facade: artifact provisioning, engine lifecycle, batch transcription, and Realtime behavior. -- `take::Take` solely owns per-take guidance, finalized history, segmentation, LocalAgreement state, transcript aggregation, completion, and failure. +- `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`. -- `/stt` keeps its existing wire path and frame contract. OpenAI multipart input is capped at 25 MiB before decode. +- 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 12f51f07..d4b425e3 100644 --- a/crates/gateway-stt/Cargo.toml +++ b/crates/gateway-stt/Cargo.toml @@ -25,9 +25,6 @@ thiserror.workspace = true tokio.workspace = true tracing.workspace = true -[target.'cfg(not(miri))'.dependencies] -workshop-server.workspace = true - [dev-dependencies] gateway-stt = { path = ".", features = ["test-fixtures"] } sha2.workspace = true diff --git a/crates/gateway-stt/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index 90add480..cefd80a3 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -4,9 +4,7 @@ public_root_budget = 6 -[migration_targets."stt.rs"] -target_step = "Step 35" -destination = "removal after the Realtime route and Workshop relay replace the legacy socket" +[migration_targets] [modules] "artifacts.rs" = 346 @@ -14,10 +12,10 @@ destination = "removal after the Realtime route and Workshop relay replace the l "batch.rs" = 347 "batch/native_tests.rs" = 113 "batch/tests.rs" = 160 -"generation.rs" = 467 +"generation.rs" = 447 "generation/lease.rs" = 130 "generation/snapshot.rs" = 158 -"lib.rs" = 41 +"lib.rs" = 39 "model.rs" = 105 "realtime/mod.rs" = 16 "realtime/input.rs" = 195 @@ -38,15 +36,14 @@ destination = "removal after the Realtime route and Workshop relay replace the l "realtime/wire/tests.rs" = 278 "replacement.rs" = 473 "segment.rs" = 253 -"service.rs" = 134 +"service.rs" = 126 "status.rs" = 54 -"stt.rs" = 725 -"take.rs" = 470 -"take/agreement.rs" = 62 +"take.rs" = 272 +"take/agreement.rs" = 30 "take/final_outcome.rs" = 98 "take/finalization.rs" = 379 -"take/interim.rs" = 132 -"take/state.rs" = 163 +"take/interim.rs" = 27 +"take/state.rs" = 157 "take/text.rs" = 9 "take/window.rs" = 250 "test_fixtures.rs" = 444 diff --git a/crates/gateway-stt/src/generation.rs b/crates/gateway-stt/src/generation.rs index cd0ba33c..89f7edfe 100644 --- a/crates/gateway-stt/src/generation.rs +++ b/crates/gateway-stt/src/generation.rs @@ -35,7 +35,6 @@ pub struct SpeechReplacement { struct Shared { publication: RwLock, next_generation: AtomicU64, - changes: tokio::sync::watch::Sender, replacements: Arc, } @@ -53,12 +52,10 @@ pub(crate) struct GenerationState { impl Default for GenerationState { fn default() -> Self { - let (changes, _receiver) = tokio::sync::watch::channel(0); Self { shared: Arc::new(Shared { publication: RwLock::new(Publication::default()), next_generation: AtomicU64::new(1), - changes, replacements: Arc::new(ReplacementCoordinator::default()), }), } @@ -159,9 +156,6 @@ impl GenerationState { let mut replacement = replacement; let published = replacement.generation.take().map(Arc::new); let configured = published.is_some(); - let revision = published - .as_ref() - .map_or_else(|| self.next_id(), |generation| generation.id); let committed = replacement.permit.with_current(|| { let mut publication = self .shared @@ -173,7 +167,6 @@ impl GenerationState { } publication.active = published; publication.configured = configured; - self.shared.changes.send_replace(revision); true }); match committed { @@ -208,7 +201,6 @@ impl GenerationState { return; }; generation.admission.shutdown(); - self.shared.changes.send_replace(self.next_id()); generation.admission.wait_until_idle(); let retired = self .shared @@ -242,10 +234,6 @@ impl GenerationState { Some((generation, mode)) } - pub(crate) fn subscribe(&self) -> tokio::sync::watch::Receiver { - self.shared.changes.subscribe() - } - pub(crate) fn status(&self) -> SpeechStatus { let publication = self .shared @@ -355,7 +343,6 @@ impl GenerationState { let close = permit .with_current(|| { let close = generation.admission.close()?; - self.shared.changes.send_replace(self.next_id()); Some(close) }) .flatten() @@ -363,13 +350,7 @@ impl GenerationState { match generation.admission.wait_for_idle(&close, deadline) { DrainOutcome::TimedOut => { let reopened = permit - .with_current(|| { - let reopened = generation.admission.reopen(&close); - if reopened { - self.shared.changes.send_replace(self.next_id()); - } - reopened - }) + .with_current(|| generation.admission.reopen(&close)) .unwrap_or(false); if reopened { Err(SpeechError::QuiescenceDeadline) @@ -429,7 +410,6 @@ fn restore_generation( if !restored { return Err(SpeechError::ReplacementInvalidated); } - shared.changes.send_replace(id); Ok(()) } diff --git a/crates/gateway-stt/src/lib.rs b/crates/gateway-stt/src/lib.rs index 3dc23bfe..f050c047 100644 --- a/crates/gateway-stt/src/lib.rs +++ b/crates/gateway-stt/src/lib.rs @@ -1,7 +1,7 @@ //! Gateway-owned speech facade and HTTP endpoints. //! //! [`SpeechService`] owns artifact preparation, complete generation -//! publication, batch transcription, and the temporary legacy socket. +//! publication, batch transcription, and Realtime transcription. mod artifacts; #[allow(dead_code)] @@ -15,8 +15,6 @@ mod replacement; mod segment; mod service; mod status; -#[cfg(not(miri))] -mod stt; mod take; #[cfg(all(test, not(feature = "test-fixtures")))] mod test_fixtures; diff --git a/crates/gateway-stt/src/service.rs b/crates/gateway-stt/src/service.rs index b642dd20..84834c3b 100644 --- a/crates/gateway-stt/src/service.rs +++ b/crates/gateway-stt/src/service.rs @@ -114,21 +114,13 @@ impl SpeechService { .force_precommit_failure(ForcedPrecommitFailure::FinalSegmentOverload); } - /// Returns the batch and temporary legacy Gateway routes. + /// Returns the batch and Realtime Gateway routes. #[cfg(not(miri))] pub fn routes(&self) -> axum::Router { - crate::batch::routes(self.state.clone()) - .merge(crate::stt::gateway_router(self.state.clone())) - .merge(crate::realtime::routes( - self.state.clone(), - self.sessions.clone(), - self.realtime_policy.clone(), - )) - } - - /// Returns the temporary Workshop-hosted legacy routes. - #[cfg(not(miri))] - pub fn workshop_routes(&self, push: workshop_server::Push) -> axum::Router { - crate::stt::workshop_router(self.state.clone(), push) + 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/stt.rs b/crates/gateway-stt/src/stt.rs deleted file mode 100644 index e05b3d03..00000000 --- a/crates/gateway-stt/src/stt.rs +++ /dev/null @@ -1,725 +0,0 @@ -//! The `/stt` WebSocket endpoint with its existing streaming wire contract. - -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; -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_stt_engine::{DecodeMode, DecodeRequest, EnginePolicy}; -use serde::Serialize; -use tokio::sync::{mpsc, watch}; -use workshop_server::{Activity, Push}; - -use crate::generation::{GenerationLease, GenerationState}; -use crate::take::Take; - -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 { - speech: GenerationState, - 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); - } -} - -pub(crate) fn workshop_router(speech: GenerationState, push: Push) -> Router { - routes_with_reporter(speech, Reporter::Workshop(push)) - .route_layer(axum::middleware::from_fn(workshop_server::cross_site_guard)) -} - -pub(crate) fn gateway_router(speech: GenerationState) -> Router { - routes_with_reporter(speech, Reporter::Silent) -} - -fn routes_with_reporter(speech: GenerationState, reporter: Reporter) -> Router { - Router::new() - .route("/stt/capability", get(capability)) - .route("/stt", get(upgrade)) - .with_state(RouteState { speech, reporter }) -} - -async fn capability(State(state): State) -> impl IntoResponse { - let status = state.speech.status(); - let gpu = status.gpu(); - let engine = status.ready(); - ( - [(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.speech, 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, - } - } -} - -#[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: GenerationLease, - state: Arc, - reporter: Reporter, -) -> ActiveTake { - let (interim_tx, interims) = watch::channel(None); - let task = InterimTask(tokio::spawn(async move { - loop { - tokio::time::sleep(engine.interval()).await; - let window = state.uncommitted_snapshot(engine.window_samples()); - let tentative = if window.len() < EnginePolicy::MIN_WINDOW_SAMPLES - || EnginePolicy::is_silence(&window) - { - String::new() - } else { - reporter.push_activity( - "Transcribing...", - "an interim pass over the uncommitted audio", - Activity::General, - ); - match engine - .decode(DecodeRequest::new( - DecodeMode::Interim, - window, - state.guidance().to_vec(), - String::new(), - )) - .await - { - Ok(text) => text, - Err(error) => { - reporter.push_activity( - "Transcription failed", - error.to_string(), - Activity::General, - ); - tracing::warn!(session, %error, "interim transcription failed"); - continue; - } - } - }; - let Some((finalized, tentative)) = state.next_interim(&tentative) else { - continue; - }; - let Ok(message) = - serde_json::to_string(&InterimFrame::new(finalized, tentative, generation)) - else { - continue; - }; - if interim_tx.send(Some(message)).is_err() { - return; - } - } - })); - ActiveTake { - interims, - _task: task, - } -} - -async fn final_transcript( - session: u64, - engine: &GenerationLease, - take: &Take, - reporter: &Reporter, -) -> String { - let window = take.fallback_snapshot(engine.window_samples()); - if window.len() < EnginePolicy::MIN_WINDOW_SAMPLES || EnginePolicy::is_silence(&window) { - return String::new(); - } - match engine - .decode(DecodeRequest::new( - DecodeMode::Interim, - window, - take.guidance().to_vec(), - String::new(), - )) - .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 / EnginePolicy::SAMPLE_RATE, - dropped / EnginePolicy::SAMPLE_RATE, - dropped % EnginePolicy::SAMPLE_RATE * 10 / EnginePolicy::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: &GenerationLease, take: &Take, reporter: &Reporter) { - let uncommitted = take.fallback_len(); - 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<&GenerationLease>, - take: &Take, - reporter: &Reporter, -) -> String { - let Some(engine) = engine else { - return String::new(); - }; - match take.complete().await { - Some(Ok(text)) => text, - Some(Err(error)) => { - reporter.push_failure("Transcription failed", error.clone(), Activity::General); - tracing::warn!( - session, - %error, - "final-pass transcription failed; falling back to the interim model" - ); - warn_if_truncated(session, engine, take, reporter); - let tail = final_transcript(session, engine, take, reporter).await; - take.fallback_transcript(&tail) - } - None => { - tracing::info!( - session, - "no final model configured; the final pass uses the interim model" - ); - warn_if_truncated(session, engine, take, reporter); - final_transcript(session, engine, take, reporter).await - } - } -} - -fn begin_take( - session: u64, - generation: u64, - active: Option<&GenerationLease>, - reporter: &Reporter, -) -> (Arc, Option) { - let engine = active.cloned(); - let guidance = active.map_or_else(Vec::new, |generation| generation.guidance().to_vec()); - let state = Arc::new(Take::new(guidance, engine.clone())); - let active = engine.map(|engine| { - spawn_interim( - session, - generation, - 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"); - (state, active) -} - -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 { - take: Arc, - frames: u64, - last_mic_pulse: Option, -} - -impl SessionAudio { - fn new() -> Self { - Self { - take: Arc::new(Take::new(Vec::new(), None)), - frames: 0, - last_mic_pulse: None, - } - } - - fn receive(&mut self, payload: &[u8], engine: Option<&GenerationLease>, 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.take.append(&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() - { - self.take.submit_closed_segments(); - } - } -} - -fn active_engine(generation: Option<&GenerationLease>) -> Option<&GenerationLease> { - generation -} - -async fn run_session( - mut socket: WebSocket, - speech: GenerationState, - 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 generation_state = speech.active(); - let mut engine_changes = speech.subscribe(); - let mut generation = 0u64; - - loop { - tokio::select! { - biased; - changed = engine_changes.changed() => { - if changed.is_err() { - break; - } - take = None; - audio.take = Arc::new(Take::new(Vec::new(), None)); - generation_state = speech.active(); - } - 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, active_engine(generation_state.as_ref()), &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; - } - let (next_take, active) = begin_take( - session, - generation, - generation_state.as_ref(), - &reporter, - ); - audio.take = next_take; - take = active; - } - STT_STOP => { - take = None; - reporter.push_status_update( - "Finalizing transcript...", - "the final pass over the take", - Activity::General, - ); - let text = stop_transcript( - session, - active_engine(generation_state.as_ref()), - &audio.take, - &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 * EnginePolicy::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 * EnginePolicy::SAMPLE_RATE, window), - Some(5 * EnginePolicy::SAMPLE_RATE) - ); - } - - #[test] - fn the_truncation_message_names_the_window_and_the_dropped_lead() { - let message = truncation_message( - 15 * EnginePolicy::SAMPLE_RATE, - 5 * EnginePolicy::SAMPLE_RATE, - ); - assert!(message.contains("15 s"), "{message}"); - assert!(message.contains("5.0 s"), "{message}"); - } - - #[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 index e3a1e277..cd4d80a6 100644 --- a/crates/gateway-stt/src/take.rs +++ b/crates/gateway-stt/src/take.rs @@ -16,14 +16,11 @@ mod state; mod text; mod window; -#[cfg(test)] -use agreement::LocalAgreement; #[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 text::append_transcript; use window::WholeWindowState; fn tail(buffer: &[f32], window: usize) -> &[f32] { @@ -105,33 +102,10 @@ impl Take { } } - pub(crate) fn fallback_snapshot(&self, window_samples: usize) -> Vec { - let finalized = self.state.finalized_samples(); - let buffer = TakeState::lock(&self.state.buffer); - let pending = &buffer[finalized.min(buffer.len())..]; - tail(pending, window_samples).to_vec() - } - - pub(crate) fn fallback_len(&self) -> usize { - let finalized = self.state.finalized_samples(); - TakeState::lock(&self.state.buffer) - .len() - .saturating_sub(finalized) - } - pub(crate) fn finalized(&self) -> String { self.state.finalized() } - pub(crate) fn next_interim(&self, hypothesis: &str) -> Option<(String, String)> { - self.next_interim_snapshot(hypothesis) - .map(InterimSnapshot::into_legacy_parts) - } - - pub(crate) fn next_interim_snapshot(&self, hypothesis: &str) -> Option { - TakeState::lock(&self.state.interim).next(&self.finalized(), hypothesis) - } - pub(crate) fn next_window_snapshot( &self, hypothesis: &str, @@ -150,12 +124,6 @@ impl Take { ) } - pub(crate) fn fallback_transcript(&self, tail: &str) -> String { - let mut transcript = self.finalized(); - append_transcript(&mut transcript, tail); - transcript - } - #[cfg(test)] fn record_finalized(&self, result: Result) { self.state.record_finalized(result, None); @@ -191,10 +159,6 @@ impl Take { let accepted = TakeState::lock(&self.whole_window).accepted_hypotheses(committed_samples); Some(pipeline.finalization(tail, consumed, committed_samples, accepted)) } - - pub(crate) async fn complete(&self) -> Option> { - Some(self.finalization()?.await) - } } #[cfg(test)] @@ -205,7 +169,7 @@ mod tests { use tokio::sync::{mpsc, oneshot}; - use super::{FinalCommand, LocalAgreement, Take, reserve_segment, run_final_pipeline}; + use super::{FinalCommand, Take, reserve_segment, run_final_pipeline}; #[test] fn miri_final_segment_reservation_is_exact() { @@ -228,66 +192,6 @@ mod tests { assert_eq!(super::tail(&[], 4), &[] as &[f32]); } - #[test] - fn local_agreement_requires_two_hypotheses_and_preserves_whitespace() { - let mut agreement = LocalAgreement::default(); - let first = agreement.observe("ask not what"); - assert_eq!(first.agreed, ""); - assert_eq!(first.tentative, "ask not what"); - - let second = agreement.observe("ask not who"); - assert_eq!(second.agreed, "ask not"); - assert_eq!(second.tentative, " who"); - assert_eq!( - format!("{}{}", second.agreed, second.tentative), - "ask not who" - ); - } - - #[test] - fn production_interims_promote_locally_agreed_words() { - let take = Take::without_final(Vec::new()); - assert_eq!( - take.next_interim("ask not what"), - Some((String::new(), "ask not what".to_owned())) - ); - assert_eq!( - take.next_interim("ask not who"), - Some(("ask not".to_owned(), "who".to_owned())) - ); - assert_eq!( - take.next_interim("ask not who"), - Some(("ask not who".to_owned(), String::new())) - ); - assert_eq!( - take.next_interim("ask not when"), - Some(("ask not who".to_owned(), "when".to_owned())) - ); - } - - #[test] - fn finalization_preserves_a_divergent_promoted_prefix() { - let take = Take::without_final(Vec::new()); - assert_eq!( - take.next_interim("ask not your country"), - Some((String::new(), "ask not your country".to_owned())) - ); - let promoted = take - .next_interim("ask not your country") - .expect("the repeated hypothesis promotes its words") - .0; - assert_eq!(promoted, "ask not your country"); - - take.record_finalized(Ok("ask not your kingdom".to_owned())); - let committed = take - .next_interim("new tail") - .expect("speech after finalization emits another interim") - .0; - - assert_eq!(committed, promoted); - assert!(committed.starts_with("ask not your country")); - } - #[test] fn finalized_history_and_guidance_are_isolated_per_take() { let first = Take::without_final(vec!["MCP".to_owned()]); @@ -319,108 +223,6 @@ mod tests { assert_eq!(failure, "first"); } - #[test] - fn tail_failure_fallback_preserves_successful_closed_segments() { - let take = Take::without_final(Vec::new()); - take.record_finalized(Ok("successful segment".to_owned())); - take.record_failure("tail failed"); - - assert_eq!(take.state.completion(&[], 0), Err("tail failed".to_owned())); - assert_eq!( - take.fallback_transcript("fallback tail"), - "successful segment fallback tail" - ); - } - - #[tokio::test] - async fn failed_segment_audio_remains_in_the_fallback_window() { - let take = Take::without_final(Vec::new()); - let successful = vec![1.0; 8_000]; - let failed = vec![2.0; 8_000]; - let skipped = vec![3.0; 8_000]; - let tail = vec![4.0; 8_000]; - take.append( - &[ - successful.clone(), - failed.clone(), - skipped.clone(), - tail.clone(), - ] - .concat(), - ); - - let (commands, receiver) = mpsc::channel(super::FINAL_SEGMENT_CAPACITY); - let calls = Arc::new(AtomicUsize::new(0)); - let decode_calls = Arc::clone(&calls); - let task = tokio::spawn(run_final_pipeline( - receiver, - Arc::from([]), - Arc::clone(&take.state), - Arc::new(AtomicUsize::new(3)), - move |_, _, _| { - let call = decode_calls.fetch_add(1, Ordering::SeqCst); - async move { - Some(match call { - 0 => Ok("successful".to_owned()), - 1 => return None, - _ => panic!("decoding must stop after the first failure"), - }) - } - }, - )); - commands - .send(FinalCommand::Segment { - samples: successful, - range: 0..8_000, - leading_silence: None, - }) - .await - .expect("the successful segment queues"); - commands - .send(FinalCommand::Segment { - samples: failed.clone(), - range: 8_000..16_000, - leading_silence: None, - }) - .await - .expect("the failed segment queues"); - commands - .send(FinalCommand::Segment { - samples: skipped.clone(), - range: 16_000..24_000, - leading_silence: None, - }) - .await - .expect("the skipped segment queues"); - let (reply, completion) = oneshot::channel(); - commands - .send(FinalCommand::Complete { - tail: tail.clone(), - start: 24_000, - committed_samples: 32_000, - accepted: Vec::new(), - reply, - }) - .await - .expect("completion queues"); - - assert!( - completion - .await - .expect("the completion pipeline replies") - .is_err() - ); - tokio::time::timeout(Duration::from_secs(1), task) - .await - .expect("the completed pipeline terminates before the deadline") - .expect("the completed pipeline task succeeds"); - assert_eq!(calls.load(Ordering::SeqCst), 2); - assert_eq!( - take.fallback_snapshot(usize::MAX), - [failed, skipped, tail].concat() - ); - } - #[tokio::test] async fn completed_pipeline_releases_its_retained_dependency() { let (commands, receiver) = mpsc::channel(super::FINAL_SEGMENT_CAPACITY); diff --git a/crates/gateway-stt/src/take/agreement.rs b/crates/gateway-stt/src/take/agreement.rs index 484aeed7..e69f529f 100644 --- a/crates/gateway-stt/src/take/agreement.rs +++ b/crates/gateway-stt/src/take/agreement.rs @@ -1,35 +1,3 @@ -#[derive(Debug, PartialEq, Eq)] -pub(super) struct AgreementSnapshot { - pub(super) agreed: String, - pub(super) tentative: String, -} - -#[derive(Debug, Default)] -pub(super) struct LocalAgreement { - previous: String, -} - -impl LocalAgreement { - pub(super) fn observe(&mut self, hypothesis: &str) -> AgreementSnapshot { - let agreed_end = if self.previous.is_empty() { - 0 - } else { - matching_token_prefix_end(&self.previous, hypothesis) - }; - self.previous.clear(); - self.previous.push_str(hypothesis); - AgreementSnapshot { - agreed: hypothesis[..agreed_end].to_owned(), - tentative: hypothesis[agreed_end..].to_owned(), - } - } - - pub(super) fn retain_tentative(&mut self, tentative: &str) { - self.previous.clear(); - self.previous.push_str(tentative); - } -} - pub(super) fn matching_token_prefix_end(previous: &str, current: &str) -> usize { let previous = token_spans(previous); let current = token_spans(current); diff --git a/crates/gateway-stt/src/take/interim.rs b/crates/gateway-stt/src/take/interim.rs index 57f71315..7e70e14d 100644 --- a/crates/gateway-stt/src/take/interim.rs +++ b/crates/gateway-stt/src/take/interim.rs @@ -1,6 +1,3 @@ -use super::agreement::{LocalAgreement, matching_token_prefix_end, token_spans}; -use super::text::append_transcript; - #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct InterimSnapshot { transcript: String, @@ -27,106 +24,4 @@ impl InterimSnapshot { pub(crate) fn into_parts(self) -> (String, String, String, String) { (self.transcript, self.finalized, self.agreed, self.tentative) } - - pub(crate) fn into_legacy_parts(self) -> (String, String) { - ( - self.committed().to_owned(), - self.tentative.trim_start().to_owned(), - ) - } -} - -#[derive(Debug, Default)] -pub(super) struct InterimState { - agreement: LocalAgreement, - promoted: String, - agreement_finalized: String, - finalized: String, - last: Option, - finalized_at_last_speech: String, -} - -impl InterimState { - pub(super) fn next(&mut self, finalized: &str, hypothesis: &str) -> Option { - if self.agreement_finalized != finalized { - let finalized_delta = finalized - .strip_prefix(&self.agreement_finalized) - .unwrap_or_default(); - let unpromoted = after_token_prefix(finalized_delta, token_spans(&self.promoted).len()); - append_transcript(&mut self.finalized, &self.promoted); - append_transcript(&mut self.finalized, unpromoted.trim()); - self.agreement = LocalAgreement::default(); - self.promoted.clear(); - self.agreement_finalized.clear(); - self.agreement_finalized.push_str(finalized); - } - let suffix_start = matching_token_prefix_end(&self.promoted, hypothesis); - let suffix = &hypothesis[suffix_start..]; - let agreement = self.agreement.observe(suffix); - let mut tentative = agreement.tentative; - self.agreement.retain_tentative(&tentative); - append_transcript(&mut self.promoted, agreement.agreed.trim()); - if !hypothesis.is_empty() { - self.finalized_at_last_speech.clear(); - self.finalized_at_last_speech.push_str(finalized); - } else if finalized.len() <= self.finalized_at_last_speech.len() { - return None; - } - let agreed = owned_piece(!self.finalized.is_empty(), &self.promoted); - if suffix_start == 0 && !self.finalized.is_empty() && self.promoted.is_empty() { - tentative = owned_piece(true, &tentative); - } - let snapshot = InterimSnapshot::new(self.finalized.clone(), agreed, tentative); - if self.last.as_ref() == Some(&snapshot) { - return (!hypothesis.is_empty()).then_some(snapshot); - } - self.last = Some(snapshot.clone()); - Some(snapshot) - } -} - -fn after_token_prefix(text: &str, tokens: usize) -> &str { - if tokens == 0 { - return text; - } - token_spans(text) - .get(tokens - 1) - .map_or("", |(_, _, end)| &text[*end..]) -} - -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 gateway_stt_engine::TranscribeError; - - use crate::take::Take; - - #[test] - fn snapshot_fields_own_disjoint_exact_text_after_divergent_finalization() { - let take = Take::without_final(Vec::new()); - take.next_interim("ask not your country"); - take.next_interim("ask not your country"); - take.record_finalized(Ok::<_, TranscribeError>("ask not your kingdom".to_owned())); - take.next_interim("new tail first"); - let snapshot = take - .next_interim_snapshot("new tail second") - .expect("new speech emits a partitioned snapshot"); - - assert_eq!( - snapshot.into_parts(), - ( - "ask not your country new tail second".to_owned(), - "ask not your country".to_owned(), - " new tail".to_owned(), - " second".to_owned() - ) - ); - } } diff --git a/crates/gateway-stt/src/take/state.rs b/crates/gateway-stt/src/take/state.rs index f92d96b1..3fd7f239 100644 --- a/crates/gateway-stt/src/take/state.rs +++ b/crates/gateway-stt/src/take/state.rs @@ -4,7 +4,6 @@ use std::sync::{Mutex, MutexGuard, PoisonError}; use gateway_stt_engine::TranscribeError; use super::final_outcome::{FinalRangeOutcome, FinalRangeResult, assemble_completion}; -use super::interim::InterimState; use super::text::append_transcript; use super::window::AcceptedHypothesis; use crate::segment::Segmenter; @@ -23,7 +22,6 @@ pub(super) struct TakeState { pub(super) buffer: Mutex>, pub(super) segmenter: Mutex, finalized: Mutex, - pub(super) interim: Mutex, } impl TakeState { @@ -94,10 +92,6 @@ impl TakeState { Self::lock(&self.finalized).failure.is_some() } - pub(super) fn finalized_samples(&self) -> usize { - Self::lock(&self.finalized).samples - } - pub(super) fn pending_failure(&self) -> Option { Self::lock(&self.finalized).failure.clone() } diff --git a/crates/gateway-stt/tests/common/mod.rs b/crates/gateway-stt/tests/common/mod.rs index ab0a0cf1..0ea85123 100644 --- a/crates/gateway-stt/tests/common/mod.rs +++ b/crates/gateway-stt/tests/common/mod.rs @@ -6,20 +6,12 @@ )] use std::path::{Path, PathBuf}; -use std::time::Duration; use axum::body::Body; use axum::http::{Request, StatusCode}; -use futures_util::{SinkExt, StreamExt}; use gateway_stt::SpeechService; -use tokio::net::TcpStream; -use tokio_tungstenite::tungstenite::Message; -use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use tower::ServiceExt as _; -pub(crate) const RECV_TIMEOUT: Duration = Duration::from_secs(10); -const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(30); - pub(crate) fn require_model() -> PathBuf { require_fixture("PROMPTFORGE_WHISPER_MODEL", "ggml-tiny.en.bin") } @@ -146,100 +138,6 @@ pub(crate) fn copy_model_replacing_token( destination } -pub(crate) fn fixture_server(with_final: bool) -> TestServer { - TestServer::spawn_with(fixture_service(with_final)) -} - -pub(crate) struct TestServer { - url: String, - task: tokio::task::JoinHandle<()>, - service: SpeechService, -} - -impl TestServer { - pub(crate) fn spawn() -> Self { - Self::spawn_with(SpeechService::new()) - } - - pub(crate) fn spawn_with(service: SpeechService) -> 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 = service.routes(); - let task = tokio::spawn(async move { - axum::serve(listener, app) - .await - .expect("gateway STT fixture serves"); - }); - Self { - url: format!("http://{address}"), - task, - service, - } - } - - pub(crate) fn ws_url(&self, path: &str) -> String { - format!( - "ws{}{}", - self.url.strip_prefix("http").expect("server URL is http"), - path - ) - } - - pub(crate) async fn shutdown(mut self) { - self.task.abort(); - let _ = tokio::time::timeout(SHUTDOWN_TIMEOUT, &mut self.task) - .await - .expect("gateway STT fixture server stops before the cleanup deadline"); - let service = self.service.clone(); - let (finished_tx, finished_rx) = tokio::sync::oneshot::channel(); - std::thread::spawn(move || { - service.shutdown(); - let _ = finished_tx.send(()); - }); - tokio::time::timeout(SHUTDOWN_TIMEOUT, finished_rx) - .await - .expect("fixture service stops before the cleanup deadline") - .expect("fixture service cleanup thread reports completion"); - } -} - -impl Drop for TestServer { - fn drop(&mut self) { - self.task.abort(); - } -} - -pub(crate) async fn send_pcm(socket: &mut JsonSocket, frames: usize) { - socket.send_binary(vec![0u8; frames * 4]).await; -} - -pub(crate) 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; - } -} - -pub(crate) async fn send_samples_once(socket: &mut JsonSocket, samples: &[f32]) { - let mut bytes = Vec::with_capacity(samples.len() * 4); - for sample in samples { - bytes.extend_from_slice(&sample.to_le_bytes()); - } - socket.send_binary(bytes).await; -} - fn wav_f32(samples: &[f32]) -> Vec { let mut bytes = std::io::Cursor::new(Vec::new()); { @@ -308,61 +206,3 @@ pub(crate) async fn transcribe_batch( let json = serde_json::from_slice(&body).expect("batch response is JSON"); (status, json) } - -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"); - } - - 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") - } - - 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; - } - } - }) - .await - .expect("matching frame arrives before deadline") - } - - pub(crate) async fn close(mut self) { - self.socket.close(None).await.expect("socket closes"); - } -} diff --git a/crates/gateway-stt/tests/it/architecture.rs b/crates/gateway-stt/tests/it/architecture.rs index 63986f2e..c7acab9f 100644 --- a/crates/gateway-stt/tests/it/architecture.rs +++ b/crates/gateway-stt/tests/it/architecture.rs @@ -20,7 +20,16 @@ const PUBLIC_ROOT_BUDGETS: [(&str, usize); 4] = [ ("gateway-whisper-ffi", 6), ]; -const DEPENDENCY_PHASE: &str = "Phase B"; +const LEGACY_WORKSHOP_UI_SPEECH_SEAMS: [&str; 6] = [ + "setupLegacyStt", + "sttCapability", + "interface StreamFrame", + "interface InterimFrame", + "interface FinalFrame", + "pcm-capture", +]; + +const DEPENDENCY_PHASE: &str = "Phase C"; const DEPENDENCY_POLICY_CRATES: [&str; 7] = [ "gateway", "gateway-stt", @@ -81,10 +90,7 @@ const DEPENDENCY_POLICIES: [DependencyPolicy; 7] = [ "gateway-stt-engine", "shared-progress", ], - temporary_edges: &[TemporaryEdge { - dependency: "workshop-server", - removal_step: "Step 35", - }], + temporary_edges: &[], }, DependencyPolicy { crate_name: "gateway-stt-engine", @@ -130,11 +136,7 @@ const DEPENDENCY_POLICIES: [DependencyPolicy; 7] = [ const MIGRATION_POLICIES: [MigrationPolicy; 4] = [ MigrationPolicy { crate_name: "gateway-stt", - targets: &[MigrationPolicyTarget { - module: "stt.rs", - target_step: "Step 35", - destination: "removal after the Realtime route and Workshop relay replace the legacy socket", - }], + targets: &[], }, MigrationPolicy { crate_name: "gateway-stt-engine", @@ -224,6 +226,14 @@ fn rust_sources(root: &Path) -> Vec { 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(|| { @@ -340,15 +350,112 @@ fn dependency_policy_omission_is_rejected() { } #[test] -fn dependency_policy_has_advanced_to_phase_b() { - assert_eq!(DEPENDENCY_PHASE, "Phase B"); +fn dependency_policy_has_advanced_to_phase_c() { + assert_eq!(DEPENDENCY_PHASE, "Phase C"); let workshop = DEPENDENCY_POLICIES .iter() .find(|policy| policy.crate_name == "workshop-server") - .unwrap_or_else(|| panic!("Phase B contains the Workshop dependency policy")); + .unwrap_or_else(|| panic!("Phase C contains the Workshop dependency policy")); assert!( workshop.final_edges.contains(&"shared-loopback"), - "Phase B adds only the Workshop dependency on shared-loopback" + "Phase C retains the Workshop dependency on shared-loopback" + ); + assert!( + DEPENDENCY_POLICIES + .iter() + .all(|policy| policy.temporary_edges.is_empty()), + "Phase C has no temporary dependency exceptions" + ); +} + +#[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" ); } @@ -556,11 +663,11 @@ fn misspelled_migration_section_is_rejected() { } #[test] -fn completed_step_18_migrations_are_removed() { +fn completed_step_39_migrations_are_removed() { let expected = expected_migration_targets("gateway-stt"); assert!(!expected.contains_key("api.rs")); assert!(!expected.contains_key("runtime.rs")); - assert_eq!(expected.keys().collect::>(), ["stt.rs"]); + assert!(expected.is_empty()); } const REFCOUNT_INTROSPECTION_OWNERS: [&str; 3] = ["Arc", "Rc", "Weak"]; diff --git a/crates/gateway-stt/tests/it/legacy_stream.rs b/crates/gateway-stt/tests/it/legacy_stream.rs deleted file mode 100644 index a7e9c539..00000000 --- a/crates/gateway-stt/tests/it/legacy_stream.rs +++ /dev/null @@ -1,600 +0,0 @@ -//! Characterization tests for the mechanically moved `/stt` socket. -//! Miri excludes these OS socket tests; native CI owns their coverage. - -#![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::test_fixtures::{ - ScriptedDecoder, ScriptedModelFactory, scripted_service, segment_ranges, -}; -use gateway_stt_engine::EnginePolicy; -use serde_json::json; -use tokio_tungstenite::tungstenite; -use tokio_tungstenite::tungstenite::client::IntoClientRequest; - -use crate::common::{ - JsonSocket, TestServer, copy_model_replacing_token, fixture_server, fixture_service, - fixture_service_with_models, jfk_samples, require_model, send_pcm, send_samples, - send_samples_once, transcribe_batch, -}; - -#[test] -fn legacy_stream_policy_constants_stay_pinned() { - let capture = gateway_config::SttPipelineConfig::default(); - assert_eq!( - EnginePolicy::SAMPLE_RATE, - 16_000, - "wire PCM stays at 16 kHz" - ); - assert_eq!( - EnginePolicy::MIN_WINDOW_SAMPLES, - EnginePolicy::SAMPLE_RATE / 2, - "interim decoding still requires half a second" - ); - assert_eq!( - capture.window_seconds(), - 15, - "the default interim window stays fifteen seconds" - ); - assert_eq!( - capture.interval_ms(), - 500, - "the default interim cadence stays 500 ms" - ); -} - -#[tokio::test] -async fn skipped_then_decoded_then_failed_falls_back_once_in_audio_order() { - let interim = ScriptedDecoder::new(); - interim.push_text("fallback whole take"); - let final_decoder = ScriptedDecoder::new(); - final_decoder.push_text("later accurate segment"); - final_decoder.push_error("following segment failed"); - let service = scripted_service( - ScriptedModelFactory::new(interim.clone()).with_final(final_decoder.clone()), - 15, - 500, - ) - .expect("scripted speech starts"); - let server = TestServer::spawn_with(service); - let mut socket = JsonSocket::connect(&server.ws_url("/stt")).await; - socket.send_text("start").await; - assert_eq!(socket.recv_json().await["type"], "stream"); - - let silence = vec![0.0; EnginePolicy::SAMPLE_RATE * 3]; - let samples = [ - vec![0.5; EnginePolicy::SAMPLE_RATE / 10], - silence.clone(), - vec![0.5; EnginePolicy::SAMPLE_RATE], - silence.clone(), - vec![0.5; EnginePolicy::SAMPLE_RATE], - silence, - ] - .concat(); - send_samples_once(&mut socket, &samples).await; - let completed = final_decoder.clone(); - assert!( - tokio::task::spawn_blocking(move || { - completed.wait_for_completed(2, Duration::from_secs(2)) - }) - .await - .expect("completion observer joins"), - "the later success and following failure complete in order: {:?}", - final_decoder.requests() - ); - - socket.send_text("stop").await; - let reply = socket - .recv_until(Duration::from_secs(1), |frame| frame["type"] == "final") - .await; - assert_eq!(reply["text"], "fallback whole take"); - assert_eq!(interim.requests().len(), 1); - - socket.close().await; - server.shutdown().await; -} - -fn transcript_words(text: &str) -> Vec { - text.split_whitespace() - .map(|word| { - word.trim_matches(|character: char| !character.is_ascii_alphanumeric()) - .to_ascii_lowercase() - }) - .filter(|word| word.len() >= 4) - .collect() -} - -fn distinguishing_word(text: &str, other: &str) -> String { - let other = transcript_words(other); - transcript_words(text) - .into_iter() - .find(|word| !other.contains(word)) - .expect("the two speech segments have distinguishable words") -} - -#[tokio::test] -#[ignore = "requires whisper test fixtures (tests/fixtures/)"] -async fn closed_segments_are_reported_in_input_order() { - let speech = jfk_samples(); - let third = speech.len() / 3; - let mut samples = speech[..third].to_vec(); - samples.extend(vec![0.0; 3 * EnginePolicy::SAMPLE_RATE]); - samples.extend_from_slice(&speech[2 * third..]); - samples.extend(vec![0.0; 3 * EnginePolicy::SAMPLE_RATE]); - let ranges = segment_ranges(&samples); - assert_eq!( - ranges.len(), - 2, - "the native fixture halves form two closed speech segments" - ); - - let service = fixture_service(true); - let (first_status, first_response) = - transcribe_batch(service.clone(), "speech-final", &samples[ranges[0].clone()]).await; - let (second_status, second_response) = - transcribe_batch(service.clone(), "speech-final", &samples[ranges[1].clone()]).await; - assert_eq!(first_status, axum::http::StatusCode::OK); - assert_eq!(second_status, axum::http::StatusCode::OK); - let first = first_response["text"] - .as_str() - .expect("first segment transcript is a string"); - let second = second_response["text"] - .as_str() - .expect("second segment transcript is a string"); - let first_marker = distinguishing_word(first, second); - let second_marker = distinguishing_word(second, first); - - let server = TestServer::spawn_with(service); - 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_once(&mut socket, &samples).await; - socket.send_text("stop").await; - let reply = socket - .recv_until(Duration::from_secs(240), |frame| frame["type"] == "final") - .await; - let final_text = reply["text"] - .as_str() - .expect("streaming final transcript is a string"); - let final_words = transcript_words(final_text); - let first_position = final_words - .iter() - .position(|word| word == &first_marker) - .expect("the streaming final contains the first segment marker"); - let second_position = final_words - .iter() - .position(|word| word == &second_marker) - .expect("the streaming final contains the second segment marker"); - assert!( - first_position < second_position, - "the /stt final preserves submitted segment order: {first_marker:?} before \ - {second_marker:?} in {final_text:?}" - ); - socket.close().await; - server.shutdown().await; -} - -#[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; - server.shutdown().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"); - server.shutdown().await; -} - -#[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; - server.shutdown().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:?}"), - } - server.shutdown().await; -} - -#[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; - server.shutdown().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; - server.shutdown().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; - server.shutdown().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; - server.shutdown().await; -} - -async fn wait_for_committed(socket: &mut JsonSocket, expected_word: &str) -> String { - socket - .recv_until(Duration::from_secs(120), |frame| { - frame["type"] == "interim" - && frame["committed"] - .as_str() - .is_some_and(|text| text.to_lowercase().contains(expected_word)) - }) - .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_model_segments_and_tail_are_authoritative_at_stop() { - 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 server = TestServer::spawn_with(service); - 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; - 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!( - !interim["tentative"] - .as_str() - .expect("interim tentative text is a string") - .to_lowercase() - .contains("kingdom"), - "the provisional transcript comes from the unmodified interim worker" - ); - send_pcm(&mut socket, 3 * 16_000).await; - let committed = wait_for_committed(&mut socket, "kingdom").await; - assert!( - !committed.to_lowercase().contains("country"), - "the closed segment comes from the vocabulary-distinguished final worker: {committed:?}" - ); - 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("kingdom") && !tail.to_lowercase().contains("country"), - "the tail comes from the vocabulary-distinguished final worker: {text:?}" - ); - socket.close().await; - server.shutdown().await; -} - -#[tokio::test] -#[ignore = "requires whisper test fixtures (tests/fixtures/)"] -async fn a_disconnected_client_does_not_break_the_next_final_take() { - let server = fixture_server(true); - let mut abandoned = JsonSocket::connect(&server.ws_url("/stt")).await; - abandoned.send_text("start").await; - assert_eq!(abandoned.recv_json().await["type"], "stream"); - send_samples(&mut abandoned, &jfk_samples()).await; - send_pcm(&mut abandoned, 3 * EnginePolicy::SAMPLE_RATE).await; - abandoned.close().await; - - let mut survivor = JsonSocket::connect(&server.ws_url("/stt")).await; - survivor.send_text("start").await; - assert_eq!(survivor.recv_json().await["type"], "stream"); - send_samples(&mut survivor, &jfk_samples()).await; - survivor.send_text("stop").await; - let reply = survivor - .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"), - "a dropped completion receiver does not poison the shared final worker: {text:?}" - ); - survivor.close().await; - server.shutdown().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, "country").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; - server.shutdown().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; - server.shutdown().await; -} diff --git a/crates/gateway-stt/tests/it/main.rs b/crates/gateway-stt/tests/it/main.rs index 681bbbaf..1da2d7cd 100644 --- a/crates/gateway-stt/tests/it/main.rs +++ b/crates/gateway-stt/tests/it/main.rs @@ -11,8 +11,6 @@ mod batch; #[cfg(not(miri))] mod generation; #[cfg(not(miri))] -mod legacy_stream; -#[cfg(not(miri))] mod realtime_fixtures; #[cfg(not(miri))] mod realtime_session; diff --git a/crates/gateway-stt/tests/it/service.rs b/crates/gateway-stt/tests/it/service.rs index 85b635df..72a9107d 100644 --- a/crates/gateway-stt/tests/it/service.rs +++ b/crates/gateway-stt/tests/it/service.rs @@ -1,11 +1,8 @@ //! Public speech-facade integration tests. -use axum::body::Body; -use axum::http::{Request, StatusCode}; use gateway_config::{Config, ProfileName}; use gateway_stt::SpeechService; use gateway_stt::test_fixtures::{ScriptedDecoder, ScriptedModelFactory, scripted_service}; -use tower::ServiceExt as _; use crate::common::fixture_service; @@ -97,26 +94,6 @@ fn physical_final_in_a_pair_cannot_claim_the_logical_realtime_identity() { assert!(error.to_string().contains("reserved"), "{error}"); } -#[tokio::test] -async fn facade_routes_keep_the_temporary_legacy_capability() { - let response = SpeechService::new() - .routes() - .oneshot( - Request::builder() - .uri("/stt/capability") - .body(Body::empty()) - .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"); - assert_eq!(&body[..], br#"{"gpu":false,"engine":false}"#); -} - #[test] #[ignore = "requires whisper test fixtures (tests/fixtures/)"] fn switch_in_loads_and_switch_out_fully_unloads_the_generation() { diff --git a/crates/gateway/AGENTS.md b/crates/gateway/AGENTS.md index 6ab0e1a0..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; 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/src/lib.rs b/crates/gateway/src/lib.rs index 58e8cfc3..907b29c4 100644 --- a/crates/gateway/src/lib.rs +++ b/crates/gateway/src/lib.rs @@ -59,8 +59,8 @@ //! 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 and -//! temporary legacy speech routes; with the +//! 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 @@ -2882,37 +2882,6 @@ mod transcription_auth_tests { ); } - #[tokio::test] - async fn stt_capability_is_mounted_behind_bearer_auth() { - let unauthorized = build_router(state(), None) - .oneshot( - Request::builder() - .uri("/stt/capability") - .body(Body::empty()) - .expect("request builds"), - ) - .await - .expect("router answers"); - assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED); - - let authorized = build_router(state(), None) - .oneshot( - Request::builder() - .uri("/stt/capability") - .header("host", "gateway.lan:8080") - .header("authorization", "Bearer test-token") - .body(Body::empty()) - .expect("request builds"), - ) - .await - .expect("router answers"); - assert_eq!(authorized.status(), StatusCode::OK); - let body = axum::body::to_bytes(authorized.into_body(), usize::MAX) - .await - .expect("body reads"); - assert_eq!(&body[..], br#"{"gpu":false,"engine":false}"#); - } - #[tokio::test] async fn unloaded_transcription_model_returns_openai_model_not_found() { const BOUNDARY: &str = "gateway-stt-boundary"; diff --git a/crates/gateway/tests/it/realtime_stt.rs b/crates/gateway/tests/it/realtime_stt.rs index 90ac3a92..067ddee1 100644 --- a/crates/gateway/tests/it/realtime_stt.rs +++ b/crates/gateway/tests/it/realtime_stt.rs @@ -993,8 +993,33 @@ fn normalized_words(transcript: &str) -> Vec { .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" + ); + } +} + #[tokio::test] -async fn gateway_auth_origin_query_and_legacy_surfaces_precede_upgrade() { +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; @@ -1072,25 +1097,7 @@ async fn gateway_auth_origin_query_and_legacy_surfaces_precede_upgrade() { cookie_socket.close(None).await.expect("socket closes"); drop(cookie_socket); - for (method, path) in [ - ("POST", "/v1/audio/transcriptions"), - ("GET", "/stt"), - ("GET", "/stt/capability"), - ] { - let response = send_within( - http.request( - reqwest::Method::from_bytes(method.as_bytes()).expect("method is valid"), - format!("http://{}{path}", strict.addr), - ) - .bearer_auth("test-token"), - ) - .await; - assert_ne!( - response.status(), - reqwest::StatusCode::NOT_FOUND, - "{method} {path} remains mounted" - ); - } + assert_final_speech_route_surface(&http, strict.addr).await; strict.shutdown().await; let trusted = server(false, &service).await; diff --git a/crates/workshop-server/AGENTS.md b/crates/workshop-server/AGENTS.md index e18cd240..9e7f464e 100644 --- a/crates/workshop-server/AGENTS.md +++ b/crates/workshop-server/AGENTS.md @@ -4,11 +4,11 @@ This crate owns the workshop HTTP/WebSocket server: loopback listener, status bu - 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. - 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/module-ceilings.toml b/crates/workshop-server/module-ceilings.toml index d5eb6e76..aca4eaba 100644 --- a/crates/workshop-server/module-ceilings.toml +++ b/crates/workshop-server/module-ceilings.toml @@ -68,7 +68,7 @@ # connection mechanics moved to their own responsibility module. "gateway.rs" = 1235 # Split from gateway.rs: fixed-target authenticated WebSocket connections -# for the legacy and Realtime relay paths. +# 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 @@ -161,18 +161,10 @@ # New module: the same-origin, payload-opaque Realtime transcription relay, # including bounded transport and hop-local control-frame ownership. "routes/realtime.rs" = 181 -# New module: the same-origin capability and WebSocket relay from the -# Workshop listener to the gateway-owned STT routes. -"routes/stt.rs" = 298 "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. diff --git a/crates/workshop-server/src/app.rs b/crates/workshop-server/src/app.rs index e4b6d8a0..d0e21db0 100644 --- a/crates/workshop-server/src/app.rs +++ b/crates/workshop-server/src/app.rs @@ -218,7 +218,6 @@ pub fn router(state: AppState) -> Router { .merge(routes::chat::routes(state.clone())) .merge(crate::session_agents::socket::routes(state.clone())) .merge(routes::realtime::routes(state.clone())) - .merge(routes::stt::routes(state.clone())) .merge(routes::gateway_config::routes(state)) .merge(with_deadline( routes::workspace::routes(workspace), @@ -232,8 +231,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)) } 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 14c0401d..00266531 100644 --- a/crates/workshop-server/src/gateway.rs +++ b/crates/workshop-server/src/gateway.rs @@ -16,7 +16,7 @@ use futures_util::stream::{self, Stream, StreamExt}; use serde::Deserialize; mod socket; -pub(crate) use socket::{GatewayRealtimeSocket, GatewaySttSocket}; +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 diff --git a/crates/workshop-server/src/gateway/socket.rs b/crates/workshop-server/src/gateway/socket.rs index 79e1d582..07c89194 100644 --- a/crates/workshop-server/src/gateway/socket.rs +++ b/crates/workshop-server/src/gateway/socket.rs @@ -7,37 +7,19 @@ use super::{GatewayClient, GatewayError}; type GatewaySocket = tokio_tungstenite::WebSocketStream>; -/// An authenticated WebSocket connection to the gateway's STT stream. -pub(crate) type GatewaySttSocket = GatewaySocket; - /// An authenticated WebSocket connection to Gateway Realtime transcription. pub(crate) type GatewayRealtimeSocket = GatewaySocket; impl GatewayClient { - /// 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 { - self.connect_socket("/stt", None, true).await - } - /// 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("/v1/realtime", Some("intent=transcription"), false) - .await + self.connect_socket().await } - async fn connect_socket( - &self, - endpoint: &str, - query: Option<&str>, - workshop_status: bool, - ) -> Result { + 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() { @@ -56,9 +38,9 @@ impl GatewayClient { "gateway URL scheme cannot be converted to WebSocket", ))) })?; - let path = format!("{}{endpoint}", url.path().trim_end_matches('/')); + let path = format!("{}/v1/realtime", url.path().trim_end_matches('/')); url.set_path(&path); - url.set_query(query); + url.set_query(Some("intent=transcription")); url.set_fragment(None); let mut request = url .as_str() @@ -73,13 +55,6 @@ impl GatewayClient { value, ); } - if workshop_status { - 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), diff --git a/crates/workshop-server/src/lib.rs b/crates/workshop-server/src/lib.rs index 1cb3370a..239e5f57 100644 --- a/crates/workshop-server/src/lib.rs +++ b/crates/workshop-server/src/lib.rs @@ -57,6 +57,12 @@ pub mod fixtures { #[cfg(feature = "test-fixtures")] pub use crate::app::fixtures::spawn_gateway; + + /// 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}; @@ -73,5 +79,5 @@ 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/routes.rs b/crates/workshop-server/src/routes.rs index 12c8226d..9ff6a613 100644 --- a/crates/workshop-server/src/routes.rs +++ b/crates/workshop-server/src/routes.rs @@ -6,5 +6,4 @@ pub(crate) mod chat; pub(crate) mod gateway_config; pub(crate) mod health; pub(crate) mod realtime; -pub(crate) mod stt; pub(crate) mod workspace; diff --git a/crates/workshop-server/src/routes/stt.rs b/crates/workshop-server/src/routes/stt.rs deleted file mode 100644 index 42825ab5..00000000 --- a/crates/workshop-server/src/routes/stt.rs +++ /dev/null @@ -1,297 +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 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..b9cff3f7 100644 --- a/crates/workshop-server/src/serve.rs +++ b/crates/workshop-server/src/serve.rs @@ -14,7 +14,7 @@ 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_progress; use crate::heartbeat; @@ -34,8 +34,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] @@ -148,35 +146,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 +161,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,17 +180,7 @@ 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 { url, @@ -251,7 +211,6 @@ fn spawn_inner( fn serve_thread( config: Config, gateway: ResolvedGateway, - routes: RouteFactory, ready: mpsc::Sender>, shutdown: tokio::sync::oneshot::Receiver<()>, stopped: &mpsc::Sender, @@ -277,7 +236,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) => { diff --git a/crates/workshop-server/tests/common/mod.rs b/crates/workshop-server/tests/common/mod.rs index 9cf86b9e..cd0686d1 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,17 @@ 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() + ) + } } impl Drop for TestServer { diff --git a/crates/workshop-server/tests/it/realtime_relay.rs b/crates/workshop-server/tests/it/realtime_relay.rs index 63df5eeb..3321590c 100644 --- a/crates/workshop-server/tests/it/realtime_relay.rs +++ b/crates/workshop-server/tests/it/realtime_relay.rs @@ -317,6 +317,25 @@ async fn canonical_sequences_cross_the_fake_upstream_unchanged_without_browser_b socket.close(None).await.expect("fixture socket closes"); } +#[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 realtime_relay_is_authenticated_fixed_and_payload_opaque() { let (gateway, probe) = spawn_probe().await; diff --git a/crates/workshop-server/ui/pcm-worklet.js b/crates/workshop-server/ui/pcm-worklet.js index 887ed1e6..a4092517 100644 --- a/crates/workshop-server/ui/pcm-worklet.js +++ b/crates/workshop-server/ui/pcm-worklet.js @@ -3,18 +3,6 @@ const OUTPUT_SAMPLE_RATE = 24_000; const DEFAULT_CHUNK_SAMPLES = OUTPUT_SAMPLE_RATE / 10; -// Legacy /stt capture remains 16 kHz mono f32 until its consumer migrates. -class PcmCaptureProcessor extends AudioWorkletProcessor { - process(inputs) { - const channel = inputs[0] && inputs[0][0]; - if (channel && channel.length > 0) { - const copy = new Float32Array(channel); - this.port.postMessage(copy.buffer, [copy.buffer]); - } - return true; - } -} - // 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. @@ -22,7 +10,7 @@ class Pcm16CaptureProcessor extends AudioWorkletProcessor { constructor(options) { super(); if (sampleRate !== OUTPUT_SAMPLE_RATE) { - throw new Error(`pcm-capture requires a 24 kHz AudioContext, received ${sampleRate} Hz`); + throw new Error(`pcm16-capture requires a 24 kHz AudioContext, received ${sampleRate} Hz`); } const requested = options && options.processorOptions && options.processorOptions.chunkSamples; this.chunkSamples = @@ -74,5 +62,4 @@ class Pcm16CaptureProcessor extends AudioWorkletProcessor { } } -registerProcessor("pcm-capture", PcmCaptureProcessor); registerProcessor("pcm16-capture", Pcm16CaptureProcessor); 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/ui/stt.ts b/crates/workshop-server/ui/src/ui/stt.ts index b275c222..3ce83e51 100644 --- a/crates/workshop-server/ui/src/ui/stt.ts +++ b/crates/workshop-server/ui/src/ui/stt.ts @@ -1,18 +1,8 @@ -// 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"; /** @@ -89,367 +79,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; -} - -// Retained with the legacy capability seam until installed-package speech -// acceptance permits Step 32 to delete the old browser protocol in one pass. -function setupLegacyStt( - 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/test/agent-stt-boot.mjs b/crates/workshop-server/ui/test/agent-stt-boot.mjs index 0bfe623d..1f810219 100644 --- a/crates/workshop-server/ui/test/agent-stt-boot.mjs +++ b/crates/workshop-server/ui/test/agent-stt-boot.mjs @@ -46,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}")`); @@ -99,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 4dd57a70..b102a8bb 100644 --- a/crates/workshop-server/ui/test/agent-stt.mjs +++ b/crates/workshop-server/ui/test/agent-stt.mjs @@ -130,7 +130,7 @@ 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; @@ -321,7 +321,7 @@ async function harness() { 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() { mic.click(); @@ -555,7 +555,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 && @@ -566,7 +566,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; @@ -616,7 +616,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; } @@ -641,7 +641,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; } @@ -711,7 +711,7 @@ 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; } @@ -753,7 +753,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; } @@ -782,7 +782,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; } @@ -867,7 +867,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; } diff --git a/crates/workshop-server/ui/test/helpers/boot.mjs b/crates/workshop-server/ui/test/helpers/boot.mjs index 03e922a9..529f678e 100644 --- a/crates/workshop-server/ui/test/helpers/boot.mjs +++ b/crates/workshop-server/ui/test/helpers/boot.mjs @@ -182,10 +182,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( @@ -195,14 +194,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}`)); }; @@ -295,8 +286,8 @@ 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("/v1/realtime")); diff --git a/crates/workshop-server/ui/test/pcm-worklet.mjs b/crates/workshop-server/ui/test/pcm-worklet.mjs index a40904e9..7c4789f0 100644 --- a/crates/workshop-server/ui/test/pcm-worklet.mjs +++ b/crates/workshop-server/ui/test/pcm-worklet.mjs @@ -45,7 +45,7 @@ async function loadProcessor( }, }); new vm.Script(source, { filename: "pcm-worklet.js" }).runInContext(context); - assert.deepEqual([...processors.keys()], ["pcm-capture", "pcm16-capture"]); + 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 }; @@ -84,19 +84,6 @@ test("the real worklet emits the shared fixture as exact little-endian PCM16", a assert.deepEqual(bytesOf(messages[0].value), fixture.bytes); }); -test("the legacy processor keeps sending copied 16 kHz float blocks", async () => { - const { processor, messages } = await loadProcessor("pcm-capture", {}, 16_000); - const input = Float32Array.from([-0.5, 0, 0.75]); - - processor.process([[input]]); - input.fill(1); - - assert.equal(messages.length, 1); - assert.equal(Object.prototype.toString.call(messages[0].value), "[object ArrayBuffer]"); - assert.equal(messages[0].transfer[0], messages[0].value); - assert.deepEqual([...new Float32Array(messages[0].value)], [-0.5, 0, 0.75]); -}); - test("the real worklet clips samples and flushes only the carried partial block", async () => { const { processor, messages, port } = await loadProcessor( "pcm16-capture", 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/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 6325d361..1cb5288c 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -791,7 +791,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 0daf0643..19238d65 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -161,7 +161,7 @@ N32 | observation | oversized-unit @ crates/gateway-stt/src/realtime/wire/server 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 +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 @@ -172,7 +172,7 @@ N43 | observation | shared-parameter-cluster @ crates/gateway/src/lib.rs::commit 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 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 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 -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 +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 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 From faf122f2cd7681dcb26acc481396048edf040805 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 06:10:14 -0700 Subject: [PATCH 50/86] Finalize generic Realtime STT architecture Replace migration allowances with exact final architecture gates, isolate Gateway builds from Workshop tooling, and make the completed speech topology discoverable from maintained documentation. Record verified debt reduction and deterministic guide generation so later changes have explicit dependency, public-surface, and source-size baselines. - `crates/gateway-stt/tests/it/architecture.rs` enforces exact workspace edges, exact public-root counts of 6, 7, 2, and 6, complete source manifests, and a 500-line maximum for every STT source module. `tools/check-stt-architecture.mjs` requires acyclic production module graphs and exact root counts for all four crates with pinned Cargo and analysis tools. - `.github/workflows/ci.yml` builds Gateway after installing only the config UI dependencies, then runs scripted and Rust architecture checks in normal CI. `.github/workflows/stt-miri.yml` removes Workshop UI setup from the native speech lane. - `crates/gateway-stt-engine/src/test_fixtures/tests.rs` separates 304 lines of deterministic worker tests from the fixture implementation so both modules satisfy the final ceiling without changing their assertions. - `AGENTS.md` and `crates/workshop-server/AGENTS.md` correct build and ownership rules. Gateway, configuration, Workshop, and source-guide documentation now describe the generic Realtime route, exact bounds, discovery facts, and payload-opaque relay. - `design/generic-realtime-stt.md` records the final ownership, dependency, wire, lifecycle, CI, and debt architecture. `design/generic-realtime-stt-acceptance.md` records passing gates, the before and after counts, refreshed generated Gateway and Workshop guides, and identical hashes for all nine generated artifacts on a clean second run. Design: replaces oversized-unit @ crates/gateway-stt-engine/src/test_fixtures/tests.rs was: crates/gateway-stt-engine/src/test_fixtures.rs::tests Design: new pure-function @ tools/check-stt-architecture.mjs::publicRootCount deps: crateName,source boundary: pub Design: new surface-growth @ tools/check-stt-architecture.mjs::publicRootCount deps: crateName,source boundary: pub Design: new pure-function @ tools/check-stt-architecture.mjs::requireExactPublicRootCount deps: actual,crateName,expected boundary: pub Design: new surface-growth @ tools/check-stt-architecture.mjs::requireExactPublicRootCount deps: actual,crateName,expected boundary: pub Design: new pure-function @ crates/gateway-stt/tests/it/architecture.rs::dependency_drift_message deps: &str Design: extends pure-function @ crates/gateway-stt/tests/it/architecture.rs::validate_module_ceiling deps: usize,usize Violates: A2 - credential ownership in crates/gateway-stt/tests/it/architecture.rs is not determinable from diff Pending: N17 - compounds Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- .github/workflows/ci.yml | 7 +- .github/workflows/stt-miri.yml | 8 - AGENTS.md | 2 +- crates/gateway-config/README.md | 2 + .../module-ceilings.toml | 6 +- .../gateway-stt-engine/module-ceilings.toml | 9 +- .../gateway-stt-engine/src/test_fixtures.rs | 307 +----------------- .../src/test_fixtures/tests.rs | 304 +++++++++++++++++ crates/gateway-stt/module-ceilings.toml | 6 +- crates/gateway-stt/tests/it/architecture.rs | 210 +++--------- .../gateway-whisper-ffi/module-ceilings.toml | 6 +- crates/gateway/README.md | 14 +- crates/workshop-server/AGENTS.md | 2 +- crates/workshop-server/README.md | 5 +- design/generic-realtime-stt-acceptance.md | 36 ++ design/generic-realtime-stt.md | 111 +++++++ guide/promptforge-gateway-guide.md | 18 +- guide/promptforge-workshop-guide.md | 17 +- guide/src/gateway/05-speech.md | 16 +- guide/src/gateway/10-serving-and-observing.md | 2 +- guide/src/workshop/01-application.md | 4 +- guide/src/workshop/07-voice.md | 13 +- tools/check-stt-architecture.mjs | 24 +- tools/check-stt-architecture.test.mjs | 15 + vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- vibe/archdoc-next.md | 2 +- 26 files changed, 595 insertions(+), 553 deletions(-) create mode 100644 crates/gateway-stt-engine/src/test_fixtures/tests.rs create mode 100644 design/generic-realtime-stt.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a9af1bcb..1915e5f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,14 +48,13 @@ jobs: RUSTUP_TOOLCHAIN=1.89 cargo install cargo-modules --version 0.25.0 --locked RUSTUP_TOOLCHAIN=1.89 cargo install cargo-public-api --version 0.52.0 --locked - - name: Install UI dependencies - working-directory: crates/workshop-server/ui - run: npm ci - - 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 diff --git a/.github/workflows/stt-miri.yml b/.github/workflows/stt-miri.yml index fb615886..0b56b38c 100644 --- a/.github/workflows/stt-miri.yml +++ b/.github/workflows/stt-miri.yml @@ -81,14 +81,6 @@ jobs: - name: Cache Cargo uses: Swatinem/rust-cache@v2 - - uses: actions/setup-node@v4 - with: - node-version: 22 - - - name: Install Workshop UI dependencies - working-directory: crates/workshop-server/ui - run: npm ci - - name: Provision pinned native fixtures shell: powershell run: | 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/crates/gateway-config/README.md b/crates/gateway-config/README.md index e92674ae..e5a1a55f 100644 --- a/crates/gateway-config/README.md +++ b/crates/gateway-config/README.md @@ -82,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-stt-backend-whisper/module-ceilings.toml b/crates/gateway-stt-backend-whisper/module-ceilings.toml index 7a31b01c..645a91a2 100644 --- a/crates/gateway-stt-backend-whisper/module-ceilings.toml +++ b/crates/gateway-stt-backend-whisper/module-ceilings.toml @@ -1,10 +1,8 @@ -# Exact source and public-root ratchets for the safe Whisper backend. +# 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_budget = 2 - -[migration_targets] +public_root_count = 2 [modules] "config.rs" = 32 diff --git a/crates/gateway-stt-engine/module-ceilings.toml b/crates/gateway-stt-engine/module-ceilings.toml index 82dbabc5..4dd79aae 100644 --- a/crates/gateway-stt-engine/module-ceilings.toml +++ b/crates/gateway-stt-engine/module-ceilings.toml @@ -1,10 +1,8 @@ -# Exact source and public-root ratchets for the backend-neutral STT engine. +# 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_budget = 7 - -[migration_targets] +public_root_count = 7 [modules] "decoder.rs" = 87 @@ -13,6 +11,7 @@ public_root_budget = 7 "lib.rs" = 18 "policy.rs" = 132 "startup.rs" = 48 -"test_fixtures.rs" = 667 +"test_fixtures.rs" = 362 +"test_fixtures/tests.rs" = 304 "translation.rs" = 50 "worker.rs" = 460 diff --git a/crates/gateway-stt-engine/src/test_fixtures.rs b/crates/gateway-stt-engine/src/test_fixtures.rs index db674abf..661bd981 100644 --- a/crates/gateway-stt-engine/src/test_fixtures.rs +++ b/crates/gateway-stt-engine/src/test_fixtures.rs @@ -359,309 +359,4 @@ impl ModelFactory for ScriptedModelFactory { } #[cfg(test)] -mod tests { - use super::*; - use crate::{EnginePolicy, SttEngine}; - - 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) { - let (state, changed) = &*decoder.shared; - let state = state.lock().unwrap_or_else(PoisonError::into_inner); - let (state, timeout) = changed - .wait_timeout_while(state, Duration::from_secs(1), |state| state.waiters == 0) - .unwrap_or_else(PoisonError::into_inner); - assert!( - !timeout.timed_out() && state.waiters == 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 parked_interim_construction_has_a_bounded_classified_outcome() { - let interim = ScriptedDecoder::new(); - interim.park_construction(); - let factory = ScriptedModelFactory::new(interim.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 || { - let result = SttEngine::new(factory, timeout); - drop(result_tx.send(result)); - }); - assert!(interim.wait_until_construction_parked(Duration::from_secs(1))); - let error = result_rx - .recv_timeout(Duration::from_secs(1)) - .expect("startup returns by its deadline") - .expect_err("parked interim construction times out"); - assert!(matches!(error, TranscribeError::InterimStartupTimedOut)); - constructor.join().expect("constructor does not panic"); - interim.release_construction(); - assert!(interim.wait_for(Duration::from_secs(1), |state| state.worker_dropped)); - } - - #[test] - fn parked_final_construction_cleans_up_the_initialized_interim_worker() { - let interim = ScriptedDecoder::new(); - let final_decoder = ScriptedDecoder::new(); - final_decoder.park_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 || { - let result = SttEngine::new(factory, timeout); - drop(result_tx.send(result)); - }); - assert!( - final_decoder.wait_until_construction_parked(Duration::from_secs(1)), - "final construction reaches its deterministic park" - ); - let error = result_rx - .recv_timeout(Duration::from_secs(1)) - .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_for(Duration::from_secs(1), |state| state.worker_dropped), - "the abandoned constructor releases its decoder after returning" - ); - } - - #[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()); - } -} +mod tests; 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..abe5359c --- /dev/null +++ b/crates/gateway-stt-engine/src/test_fixtures/tests.rs @@ -0,0 +1,304 @@ +use super::*; +use crate::{EnginePolicy, SttEngine}; + +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) { + let (state, changed) = &*decoder.shared; + let state = state.lock().unwrap_or_else(PoisonError::into_inner); + let (state, timeout) = changed + .wait_timeout_while(state, Duration::from_secs(1), |state| state.waiters == 0) + .unwrap_or_else(PoisonError::into_inner); + assert!( + !timeout.timed_out() && state.waiters == 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 parked_interim_construction_has_a_bounded_classified_outcome() { + let interim = ScriptedDecoder::new(); + interim.park_construction(); + let factory = ScriptedModelFactory::new(interim.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 || { + let result = SttEngine::new(factory, timeout); + drop(result_tx.send(result)); + }); + assert!(interim.wait_until_construction_parked(Duration::from_secs(1))); + let error = result_rx + .recv_timeout(Duration::from_secs(1)) + .expect("startup returns by its deadline") + .expect_err("parked interim construction times out"); + assert!(matches!(error, TranscribeError::InterimStartupTimedOut)); + constructor.join().expect("constructor does not panic"); + interim.release_construction(); + assert!(interim.wait_for(Duration::from_secs(1), |state| state.worker_dropped)); +} + +#[test] +fn parked_final_construction_cleans_up_the_initialized_interim_worker() { + let interim = ScriptedDecoder::new(); + let final_decoder = ScriptedDecoder::new(); + final_decoder.park_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 || { + let result = SttEngine::new(factory, timeout); + drop(result_tx.send(result)); + }); + assert!( + final_decoder.wait_until_construction_parked(Duration::from_secs(1)), + "final construction reaches its deterministic park" + ); + let error = result_rx + .recv_timeout(Duration::from_secs(1)) + .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_for(Duration::from_secs(1), |state| state.worker_dropped), + "the abandoned constructor releases its decoder after returning" + ); +} + +#[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/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index cefd80a3..0eac67bd 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -1,10 +1,8 @@ -# Exact source and public-root ratchets for the gateway STT facade. +# 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_budget = 6 - -[migration_targets] +public_root_count = 6 [modules] "artifacts.rs" = 346 diff --git a/crates/gateway-stt/tests/it/architecture.rs b/crates/gateway-stt/tests/it/architecture.rs index c7acab9f..bae1fb50 100644 --- a/crates/gateway-stt/tests/it/architecture.rs +++ b/crates/gateway-stt/tests/it/architecture.rs @@ -13,7 +13,7 @@ const STT_CRATES: [&str; 4] = [ "gateway-whisper-ffi", ]; -const PUBLIC_ROOT_BUDGETS: [(&str, usize); 4] = [ +const PUBLIC_ROOT_COUNTS: [(&str, usize); 4] = [ ("gateway-stt", 6), ("gateway-stt-engine", 7), ("gateway-stt-backend-whisper", 2), @@ -29,7 +29,6 @@ const LEGACY_WORKSHOP_UI_SPEECH_SEAMS: [&str; 6] = [ "pcm-capture", ]; -const DEPENDENCY_PHASE: &str = "Phase C"; const DEPENDENCY_POLICY_CRATES: [&str; 7] = [ "gateway", "gateway-stt", @@ -43,23 +42,6 @@ const DEPENDENCY_POLICY_CRATES: [&str; 7] = [ struct DependencyPolicy { crate_name: &'static str, final_edges: &'static [&'static str], - temporary_edges: &'static [TemporaryEdge], -} - -struct TemporaryEdge { - dependency: &'static str, - removal_step: &'static str, -} - -struct MigrationPolicy { - crate_name: &'static str, - targets: &'static [MigrationPolicyTarget], -} - -struct MigrationPolicyTarget { - module: &'static str, - target_step: &'static str, - destination: &'static str, } const DEPENDENCY_POLICIES: [DependencyPolicy; 7] = [ @@ -79,7 +61,6 @@ const DEPENDENCY_POLICIES: [DependencyPolicy; 7] = [ "shared-protocol", "shared-sidecar", ], - temporary_edges: &[], }, DependencyPolicy { crate_name: "gateway-stt", @@ -90,12 +71,10 @@ const DEPENDENCY_POLICIES: [DependencyPolicy; 7] = [ "gateway-stt-engine", "shared-progress", ], - temporary_edges: &[], }, DependencyPolicy { crate_name: "gateway-stt-engine", final_edges: &[], - temporary_edges: &[], }, DependencyPolicy { crate_name: "gateway-stt-backend-whisper", @@ -104,17 +83,14 @@ const DEPENDENCY_POLICIES: [DependencyPolicy; 7] = [ "gateway-whisper-ffi", "shared-progress", ], - temporary_edges: &[], }, DependencyPolicy { crate_name: "gateway-whisper-ffi", final_edges: &[], - temporary_edges: &[], }, DependencyPolicy { crate_name: "shared-loopback", final_edges: &[], - temporary_edges: &[], }, DependencyPolicy { crate_name: "workshop-server", @@ -129,44 +105,16 @@ const DEPENDENCY_POLICIES: [DependencyPolicy; 7] = [ "shared-progress", "shared-sidecar", ], - temporary_edges: &[], - }, -]; - -const MIGRATION_POLICIES: [MigrationPolicy; 4] = [ - MigrationPolicy { - crate_name: "gateway-stt", - targets: &[], - }, - MigrationPolicy { - crate_name: "gateway-stt-engine", - targets: &[], - }, - MigrationPolicy { - crate_name: "gateway-stt-backend-whisper", - targets: &[], - }, - MigrationPolicy { - crate_name: "gateway-whisper-ffi", - targets: &[], }, ]; #[derive(Debug, serde::Deserialize)] #[serde(deny_unknown_fields)] struct CeilingsFile { - public_root_budget: usize, - migration_targets: BTreeMap, + public_root_count: usize, modules: BTreeMap, } -#[derive(Debug, Eq, PartialEq, serde::Deserialize)] -#[serde(deny_unknown_fields)] -struct MigrationTarget { - target_step: String, - destination: String, -} - #[derive(serde::Deserialize)] struct CargoMetadata { packages: Vec, @@ -305,42 +253,45 @@ fn validate_dependency_policies(policies: &[DependencyPolicy]) -> Result<(), Str } if actual != expected { return Err(format!( - "dependency policies must cover the exact {DEPENDENCY_PHASE} crates: expected \ - {expected:?}, got {actual:?}" + "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_phase_specific_allowlists() { +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 mut allowed = policy + let allowed = policy .final_edges .iter() .copied() .map(str::to_owned) .collect::>(); - for edge in policy.temporary_edges { - assert!( - edge.removal_step.starts_with("Step "), - "{} -> {} must name its removal step", - policy.crate_name, - edge.dependency - ); - allowed.insert(edge.dependency.to_owned()); - } assert_eq!( crate_workspace_edges(metadata, policy.crate_name), allowed, - "{} workspace edges drifted from the current phase allowlist", - policy.crate_name + "{}", + 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!( @@ -350,21 +301,23 @@ fn dependency_policy_omission_is_rejected() { } #[test] -fn dependency_policy_has_advanced_to_phase_c() { - assert_eq!(DEPENDENCY_PHASE, "Phase C"); +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!("Phase C contains the Workshop dependency policy")); + .unwrap_or_else(|| panic!("final policy contains the Workshop dependency policy")); assert!( workshop.final_edges.contains(&"shared-loopback"), - "Phase C retains the Workshop dependency on shared-loopback" + "final policy retains the Workshop dependency on shared-loopback" ); assert!( - DEPENDENCY_POLICIES + !DEPENDENCY_POLICIES .iter() - .all(|policy| policy.temporary_edges.is_empty()), - "Phase C has no temporary dependency exceptions" + .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" ); } @@ -526,79 +479,36 @@ fn relative_source_path(src: &Path, source: &Path) -> String { .replace('\\', "/") } -fn expected_migration_targets(crate_name: &str) -> BTreeMap { - MIGRATION_POLICIES - .iter() - .find(|policy| policy.crate_name == crate_name) - .unwrap_or_else(|| panic!("migration policy must cover {crate_name}")) - .targets - .iter() - .map(|target| { - ( - target.module.to_owned(), - MigrationTarget { - target_step: target.target_step.to_owned(), - destination: target.destination.to_owned(), - }, - ) - }) - .collect() -} - -fn expected_public_root_budget(crate_name: &str) -> usize { - PUBLIC_ROOT_BUDGETS +fn expected_public_root_count(crate_name: &str) -> usize { + PUBLIC_ROOT_COUNTS .iter() - .find_map(|(name, budget)| (*name == crate_name).then_some(*budget)) + .find_map(|(name, count)| (*name == crate_name).then_some(*count)) .unwrap_or_else(|| panic!("public-root policy must cover {crate_name}")) } -fn validate_migration_targets(crate_name: &str, config: &CeilingsFile) -> Result<(), String> { - let expected = expected_migration_targets(crate_name); - if config.migration_targets != expected { - return Err(format!( - "{crate_name} migration targets must match the exact phase policy: expected {expected:?}, got {:?}", - config.migration_targets - )); - } - for module in config.migration_targets.keys() { - if !config.modules.contains_key(module) { - return Err(format!( - "{crate_name} migration target names unknown module {module}" - )); - } - } - Ok(()) -} - -fn validate_module_ceiling( - lines: usize, - ceiling: usize, - settled_limit: Option, -) -> Result<(), String> { +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 let Some(limit) = settled_limit - && lines > limit - { + if lines > 500 { return Err(format!( - "settled module has {lines} physical lines above the {limit}-line limit" + "final module has {lines} physical lines above the 500-line limit" )); } Ok(()) } #[test] -fn module_ceilings_cover_sources_and_name_migration_targets() { +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_budget, - expected_public_root_budget(crate_name), - "{crate_name} public root budget drifted from the exact phase policy" + config.public_root_count, + expected_public_root_count(crate_name), + "{crate_name} public root count drifted from the exact final policy" ); let measured = rust_sources(&src) .into_iter() @@ -616,44 +526,26 @@ fn module_ceilings_cover_sources_and_name_migration_targets() { ); for (module, lines) in measured { let ceiling = config.modules[&module]; - let settled_limit = (crate_name == "gateway-stt" - && !config.migration_targets.contains_key(&module)) - .then_some(500); - validate_module_ceiling(lines, ceiling, settled_limit).unwrap_or_else(|error| { + validate_module_ceiling(lines, ceiling).unwrap_or_else(|error| { panic!("{crate_name}/{module} violates its source policy: {error}") }); } - validate_migration_targets(crate_name, &config).unwrap_or_else(|error| panic!("{error}")); } } #[test] -fn exact_module_ceiling_policy_rejects_spare_growth_and_settled_oversize() { - assert!(validate_module_ceiling(499, 500, Some(500)).is_err()); - assert!(validate_module_ceiling(501, 501, Some(500)).is_err()); - assert!(validate_module_ceiling(500, 500, Some(500)).is_ok()); - assert!( - validate_module_ceiling(501, 501, None).is_ok(), - "a named migration may retain an exact temporary oversize" - ); +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 completed_engine_migration_targets_are_removed() { - let config = CeilingsFile { - public_root_budget: 7, - migration_targets: BTreeMap::new(), - modules: BTreeMap::from([("engine.rs".to_owned(), 1), ("worker.rs".to_owned(), 1)]), - }; - assert!(validate_migration_targets("gateway-stt-engine", &config).is_ok()); -} - -#[test] -fn misspelled_migration_section_is_rejected() { +fn stale_migration_section_is_rejected() { let malformed = r#" - public_root_budget = 2 + public_root_count = 2 - [migration_targtes] + [migration_targets] [modules] "lib.rs" = 1 @@ -662,14 +554,6 @@ fn misspelled_migration_section_is_rejected() { assert!(parse_ceilings(malformed).is_err()); } -#[test] -fn completed_step_39_migrations_are_removed() { - let expected = expected_migration_targets("gateway-stt"); - assert!(!expected.contains_key("api.rs")); - assert!(!expected.contains_key("runtime.rs")); - assert!(expected.is_empty()); -} - const REFCOUNT_INTROSPECTION_OWNERS: [&str; 3] = ["Arc", "Rc", "Weak"]; const REFCOUNT_INTROSPECTION_METHODS: [&str; 11] = [ "decrement_strong_count", diff --git a/crates/gateway-whisper-ffi/module-ceilings.toml b/crates/gateway-whisper-ffi/module-ceilings.toml index 4440174b..d7db5027 100644 --- a/crates/gateway-whisper-ffi/module-ceilings.toml +++ b/crates/gateway-whisper-ffi/module-ceilings.toml @@ -1,10 +1,8 @@ -# Exact source and public-root ratchets for the Whisper FFI leaf. +# 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_budget = 6 - -[migration_targets] +public_root_count = 6 [modules] "context.rs" = 226 diff --git a/crates/gateway/README.md b/crates/gateway/README.md index 97a078b3..68a98a65 100644 --- a/crates/gateway/README.md +++ b/crates/gateway/README.md @@ -22,7 +22,7 @@ The config path comes from the `--config` flag or the `PROMPTFORGE_GATEWAY_CONFI 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`, streaming dictation at `/stt`, capability discovery at `GET /stt/capability`, and OpenAI-compatible multipart transcription at `POST /v1/audio/transcriptions`. +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()`). @@ -92,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, `WS /v1/realtime?intent=transcription`, 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 because its `bind` and `open_browser` settings are inert. +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 @@ -133,6 +133,14 @@ Legacy `[workshop.stt]` input is accepted only when `[stt]` is absent. Defining | `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/workshop-server/AGENTS.md b/crates/workshop-server/AGENTS.md index 9e7f464e..c2153c51 100644 --- a/crates/workshop-server/AGENTS.md +++ b/crates/workshop-server/AGENTS.md @@ -2,7 +2,7 @@ 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 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. diff --git a/crates/workshop-server/README.md b/crates/workshop-server/README.md index e28f0342..051f185b 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 | @@ -69,7 +70,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/design/generic-realtime-stt-acceptance.md b/design/generic-realtime-stt-acceptance.md index 4d2b5a74..2d71d467 100644 --- a/design/generic-realtime-stt-acceptance.md +++ b/design/generic-realtime-stt-acceptance.md @@ -13,6 +13,42 @@ Accepted by the operator for the installed unsigned package built from current H - Signing: not tested - Commit created: no +## 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 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/guide/promptforge-gateway-guide.md b/guide/promptforge-gateway-guide.md index 1f2511a2..3d73bdea 100644 --- a/guide/promptforge-gateway-guide.md +++ b/guide/promptforge-gateway-guide.md @@ -388,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 @@ -422,7 +422,7 @@ The `window_seconds` key sets the seconds of trailing audio transcribed per pass Legacy `[workshop.stt]` input is accepted only when `[stt]` is absent. Defining both is rejected, and saved configuration uses only `[stt]`. -## The transcription endpoint +## 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[]`. @@ -442,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 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 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 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. +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 /stt socket refuses cross-site browser connections: the upgrade performs an Origin allowlist check and answers 403. +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. -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. +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. + +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. @@ -772,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 4795b4fc..23269690 100644 --- a/guide/promptforge-workshop-guide.md +++ b/guide/promptforge-workshop-guide.md @@ -53,7 +53,7 @@ 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: @@ -62,6 +62,8 @@ The generated config is a single editable TOML file with a header that invites e A `gateway.toml` carried over from an older version may declare a `[workshop]` section. It still parses: the gateway logs a deprecation warning, and its `bind` and `open_browser` settings do nothing because the Workshop's server now lives inside the desktop application. Speech pipeline tuning belongs in `[stt]`; legacy `[workshop.stt]` input migrates only when `[stt]` is absent. +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. ## The Workshop configuration @@ -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 diff --git a/guide/src/gateway/05-speech.md b/guide/src/gateway/05-speech.md index 22caed79..54ac36ea 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 @@ -34,7 +34,7 @@ The `window_seconds` key sets the seconds of trailing audio transcribed per pass Legacy `[workshop.stt]` input is accepted only when `[stt]` is absent. Defining both is rejected, and saved configuration uses only `[stt]`. -## The transcription endpoint +## 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[]`. @@ -54,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 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 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 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. +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 /stt socket refuses cross-site browser connections: the upgrade performs an Origin allowlist check and answers 403. +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. -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. +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. + +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 6228c096..db1ce09b 100644 --- a/guide/src/gateway/10-serving-and-observing.md +++ b/guide/src/gateway/10-serving-and-observing.md @@ -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 628ed938..d337d597 100644 --- a/guide/src/workshop/01-application.md +++ b/guide/src/workshop/01-application.md @@ -49,7 +49,7 @@ 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: @@ -58,6 +58,8 @@ The generated config is a single editable TOML file with a header that invites e A `gateway.toml` carried over from an older version may declare a `[workshop]` section. It still parses: the gateway logs a deprecation warning, and its `bind` and `open_browser` settings do nothing because the Workshop's server now lives inside the desktop application. Speech pipeline tuning belongs in `[stt]`; legacy `[workshop.stt]` input migrates only when `[stt]` is absent. +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. ## The Workshop configuration diff --git a/guide/src/workshop/07-voice.md b/guide/src/workshop/07-voice.md index 57462728..c9b07c5a 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 diff --git a/tools/check-stt-architecture.mjs b/tools/check-stt-architecture.mjs index 6be87d71..7dafdc3a 100644 --- a/tools/check-stt-architecture.mjs +++ b/tools/check-stt-architecture.mjs @@ -211,18 +211,26 @@ export function countEffectiveRootNames(output, crateName) { return names.size; } -function publicRootBudget(source, crateName) { +export function publicRootCount(source, crateName) { const matches = [ - ...source.matchAll(/^\s*public_root_budget\s*=\s*(\d+)\s*$/gm), + ...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_budget`, + `${crateName}/module-ceilings.toml must contain exactly one integer 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}`, + ); + } +} + export function runCargo( root, args, @@ -299,13 +307,9 @@ function main() { crateName.replaceAll("-", "_"), ); const ceilingPath = join(root, "crates", crateName, "module-ceilings.toml"); - const budget = publicRootBudget(readFileSync(ceilingPath, "utf8"), crateName); - if (rootNames > budget) { - fail( - `${crateName} exposes ${rootNames} effective root names past its budget ${budget}`, - ); - } - console.log(`${crateName}: acyclic, public roots ${rootNames}/${budget}`); + const expected = publicRootCount(readFileSync(ceilingPath, "utf8"), crateName); + requireExactPublicRootCount(crateName, rootNames, expected); + console.log(`${crateName}: acyclic, public roots ${rootNames}`); } } diff --git a/tools/check-stt-architecture.test.mjs b/tools/check-stt-architecture.test.mjs index 1ce1eac7..ee5b0e18 100644 --- a/tools/check-stt-architecture.test.mjs +++ b/tools/check-stt-architecture.test.mjs @@ -5,7 +5,9 @@ import { assertAcyclic, countEffectiveRootNames, parseCargoModulesDot, + publicRootCount, requireCargoVersion, + requireExactPublicRootCount, requireToolVersion, runCargo, } from "./check-stt-architecture.mjs"; @@ -90,6 +92,19 @@ test("public API parser rejects malformed 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("tool version parser rejects an unpinned version", () => { assert.throws( () => requireToolVersion("cargo-modules", "cargo-modules 0.26.0\n", "0.25.0"), diff --git a/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 1cb5288c..b4f1c600 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -805,7 +805,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 19238d65..97a79204 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -142,7 +142,7 @@ N13 | observation | global-state @ crates/gateway-stt-backend-whisper/src/prompt 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 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 -N17 | observation | Violates A2 @ crates/gateway-stt/tests/it/architecture.rs: not determinable from diff | Enforce exact STT architecture ratchets +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 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 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 From d79823ed723b155a77d704e8861c1f1e7e00e6c1 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 06:30:47 -0700 Subject: [PATCH 51/86] Bookend Gateway serving file logs Bookend every serving file log with a versioned launch record and one terminal outcome. Keep the launch record first, place fatal termination after the complete error chain, and leave no-subscriber launches on their existing output paths. - `main` emits terminal records only when `logging.is_some()` and shuts down the runtime afterward, so the final file record drains before process exit. - `init_logging` emits `promptforge-gateway {} starting` immediately after subscriber installation and before `logging to {}`. - `headless_serve_bookends_the_log_file` spawns the real executable, waits for its connection file, posts the shutdown route, waits for successful child exit, and asserts the first and last log lines. - `a_fatal_boot_error_lands_in_the_log_with_its_chain` runs a failing child and asserts that the fatal terminal record follows the last `caused by:` record and remains last. - `main` still returns before `init_logging` for help, version, diagnostics, and second-instance handoff. `init_logging` keeps both stdout-only branches without a file runtime, and `print_error_chain` remains the no-subscriber error fallback. Design: new surface-growth @ crates/gateway/src/main.rs::main boundary: persisted Design: new surface-growth @ crates/gateway/src/main.rs::init_logging boundary: persisted Violates: A2 - not determinable from diff Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- crates/gateway/src/main.rs | 9 ++- crates/gateway/tests/it/boot.rs | 94 +++++++++++++++++++---- vibe/2026-09-05-2-generic-realtime-stt.md | 2 +- 3 files changed, 86 insertions(+), 19 deletions(-) diff --git a/crates/gateway/src/main.rs b/crates/gateway/src/main.rs index 3e4231f9..c385e29c 100644 --- a/crates/gateway/src/main.rs +++ b/crates/gateway/src/main.rs @@ -123,13 +123,19 @@ fn main() -> ExitCode { run(&invocation.serve) }; let exit = match result { - Ok(()) => ExitCode::SUCCESS, + Ok(()) => { + if logging.is_some() { + tracing::info!("gateway exiting"); + } + ExitCode::SUCCESS + } Err(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); } @@ -172,6 +178,7 @@ fn init_logging() -> Option { .with(stdout) .with(file_layer) .init(); + tracing::info!("promptforge-gateway {} starting", env!("CARGO_PKG_VERSION")); tracing::info!("logging to {}", runtime.path().display()); Some(runtime) } diff --git a/crates/gateway/tests/it/boot.rs b/crates/gateway/tests/it/boot.rs index 1d0f85d4..7f80c060 100644 --- a/crates/gateway/tests/it/boot.rs +++ b/crates/gateway/tests/it/boot.rs @@ -212,19 +212,21 @@ models = ["missing-model"] handle.shutdown().expect("graceful shutdown"); } -/// A headless invocation with `--config` 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") @@ -233,6 +235,8 @@ fn headless_serve_writes_the_startup_line_to_the_log_file() { let mut child = 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()) @@ -242,26 +246,66 @@ 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 @@ -635,6 +679,22 @@ fn a_fatal_boot_error_lands_in_the_log_with_its_chain() { 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/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index b4f1c600..053fb2f3 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -817,7 +817,7 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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. From 13fb8eef17958dc301e0198fa1c33d57f7ea9858 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 07:03:41 -0700 Subject: [PATCH 52/86] Recover Workshop after local Gateway exits Supervise local sidecar Gateways and publish each validated replacement as one endpoint and credential generation. Wake heartbeat, progress, catalog, chat, proxy, and Realtime consumers only after the complete snapshot is live, while explicit LAN targets stay fixed. Preserve configured bearer identity across unchanged restarts and accept replacement identity by process or boot data, independent of port reuse. Complete all release, package, recovery, and operator acceptance gates. - `GatewayBinding` centralizes the HTTP client, model client, endpoint, bearer, and generation in one immutable snapshot. Replacement builds the complete snapshot before atomic publication and consumer notification. - `run_supervision` re-resolves the connection file, validates process image, health, and bearer acceptance, and launches the installed sibling under bounded backoff when no live local Gateway remains. New process or boot identity permits unchanged ports and keys, while configured key edits publish with their replacement. - `composeTranscript` owns one separator only for standalone dictation at the logical document end. Hypotheses, completions, rollback, selected replacement, and producer-supplied whitespace keep consistent composition. - `design/generic-realtime-stt-acceptance.md` records the complete release suite, native and architecture gates, generated-document hashes, installed package identities, recovery after more than 60 seconds, and final operator acceptance. Signing remains untested and deferred to release CI. Design: new facade @ crates/workshop-server/src/gateway_binding.rs::GatewayBinding Design: new parameter-object @ crates/workshop-server/src/gateway_binding.rs::GatewayBinding Design: new shared-mutable-state @ crates/workshop-server/src/gateway_binding.rs::GatewayBinding Design: new surface-growth @ crates/workshop-server/src/gateway_binding.rs::GatewayUpdater boundary: pub Design: new surface-growth @ crates/workshop-server/src/gateway.rs::GatewayError::InvalidSidecar boundary: pub Design: new surface-growth @ crates/workshop-server/src/serve.rs::ServerHandle::gateway_updater boundary: pub Design: new surface-growth @ crates/workshop-server/src/lib.rs::fixtures::gateway_updater boundary: pub Design: extends oversized-unit @ crates/workshop-server/src/session_agents/supervisor.rs::spawn deps: AgentSession,AgentSessions,GatewayBinding,SessionHost Design: new shared-mutable-state @ crates/workshop/src/main.rs::GatewaySlot Design: extends service-locator @ crates/workshop/src/main.rs::run Design: new pure-function @ crates/workshop/src/gateway.rs::same_gateway_identity deps: &ConnectionFile,&ConnectionFile Design: extends parallel-abstraction @ crates/workshop-server/ui/src/ui/realtime-stt.ts::Take Design: extends oversized-unit @ crates/workshop-server/ui/src/ui/realtime-stt.ts::setupStt deps: RealtimeTranscriptionService,SpeechCaptureService,SttBlocker,SttElements,SttStatus boundary: pub Design: extends surface-growth @ crates/workshop-server/ui/src/ui/stt.ts::SttInputTarget boundary: pub Design: new pure-function @ crates/workshop-server/ui/test/agent-stt.mjs::producerHypothesis deps: itemId,revision,transcript Design: new pure-function @ crates/workshop-server/ui/test/agent-stt.mjs::producerCommitted deps: itemId Design: new pure-function @ crates/workshop-server/ui/test/agent-stt.mjs::producerCompletion deps: itemId,transcript Pending: N57 - compounds Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- Cargo.lock | 10 + crates/workshop-server/Cargo.toml | 1 + crates/workshop-server/module-ceilings.toml | 13 + crates/workshop-server/src/app.rs | 33 +- crates/workshop-server/src/gateway.rs | 12 +- crates/workshop-server/src/gateway_binding.rs | 267 ++++++++++ .../workshop-server/src/gateway_progress.rs | 82 +-- .../src/gateway_progress/tests/lifecycle.rs | 7 +- .../src/gateway_progress/tests/recovery.rs | 38 ++ crates/workshop-server/src/heartbeat.rs | 230 +++------ .../workshop-server/src/heartbeat/refresh.rs | 117 +++++ .../src/heartbeat/tests/recovery.rs | 59 +++ crates/workshop-server/src/lib.rs | 29 +- crates/workshop-server/src/relay.rs | 3 +- .../src/routes/gateway_config.rs | 13 +- .../src/routes/gateway_config/tests.rs | 2 + .../routes/gateway_config/tests/recovery.rs | 55 ++ crates/workshop-server/src/routes/realtime.rs | 3 +- crates/workshop-server/src/serve.rs | 23 +- crates/workshop-server/src/session/menu.rs | 2 +- crates/workshop-server/src/session_agents.rs | 52 +- .../src/session_agents/lifecycle.rs | 2 + .../src/session_agents/supervisor.rs | 125 ++--- .../src/session_agents/supervisor/catalog.rs | 53 ++ crates/workshop-server/tests/common/mod.rs | 23 + crates/workshop-server/tests/it/chat_gate.rs | 70 ++- .../tests/it/realtime_relay.rs | 54 ++ .../workshop-server/ui/src/ui/prompt-input.ts | 5 + .../workshop-server/ui/src/ui/realtime-stt.ts | 27 +- crates/workshop-server/ui/src/ui/stt.ts | 3 + crates/workshop-server/ui/test/agent-stt.mjs | 167 ++++++ crates/workshop/src/gateway.rs | 362 ++++++++++++- crates/workshop/src/main.rs | 44 +- design/generic-realtime-stt-acceptance.md | 476 +++++++++++++++++- vibe/2026-09-05-2-generic-realtime-stt.md | 8 +- vibe/archdoc-next.md | 2 +- 36 files changed, 2115 insertions(+), 357 deletions(-) create mode 100644 crates/workshop-server/src/gateway_binding.rs create mode 100644 crates/workshop-server/src/gateway_progress/tests/recovery.rs create mode 100644 crates/workshop-server/src/heartbeat/refresh.rs create mode 100644 crates/workshop-server/src/heartbeat/tests/recovery.rs create mode 100644 crates/workshop-server/src/routes/gateway_config/tests/recovery.rs create mode 100644 crates/workshop-server/src/session_agents/supervisor/catalog.rs diff --git a/Cargo.lock b/Cargo.lock index a5458d76..08c5f0ae 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" @@ -8307,6 +8316,7 @@ name = "workshop-server" version = "0.2.0" dependencies = [ "anyhow", + "arc-swap", "async-trait", "axum", "build-ui", diff --git a/crates/workshop-server/Cargo.toml b/crates/workshop-server/Cargo.toml index 09f40ba0..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 diff --git a/crates/workshop-server/module-ceilings.toml b/crates/workshop-server/module-ceilings.toml index aca4eaba..114fc8d1 100644 --- a/crates/workshop-server/module-ceilings.toml +++ b/crates/workshop-server/module-ceilings.toml @@ -67,6 +67,9 @@ # 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 # Split from gateway.rs: fixed-target authenticated WebSocket connections # for the Realtime relay path. "gateway/socket.rs" = 94 @@ -80,12 +83,18 @@ # 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 @@ -157,6 +166,8 @@ # 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, payload-opaque Realtime transcription relay, # including bounded transport and hop-local control-frame ownership. @@ -194,6 +205,8 @@ # New module: one agent session's run lifecycle across turn cancellation, # delayed catalog readiness, and usable chat-catalog replacement. "session_agents/supervisor.rs" = 170 +# Catalog-generation waits split from agent run orchestration. +"session_agents/supervisor/catalog.rs" = 53 # 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 d0e21db0..dd153d19 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,15 @@ 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(gateway.base_url(), gateway.api_key()).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 +189,7 @@ pub fn state_with_gateway( ); push.push_idle(); Ok(AppState { - gateway: client, + gateway: gateway_binding, status, progress, health: GatewayHealth::new(), @@ -331,6 +347,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/gateway.rs b/crates/workshop-server/src/gateway.rs index 00266531..e7e617af 100644 --- a/crates/workshop-server/src/gateway.rs +++ b/crates/workshop-server/src/gateway.rs @@ -247,6 +247,14 @@ pub fn switch_events(payloads: SsePayloadStream) -> SwitchEventStream { #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum GatewayError { + /// A local-sidecar connection file failed its structural validation. + #[non_exhaustive] + #[error("invalid local gateway connection: {reason}")] + InvalidSidecar { + /// The rejected structural property. + reason: &'static str, + }, + /// The HTTP client could not be built. #[non_exhaustive] #[error("build gateway http client")] @@ -270,8 +278,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, diff --git a/crates/workshop-server/src/gateway_binding.rs b/crates/workshop-server/src/gateway_binding.rs new file mode 100644 index 00000000..92ee7295 --- /dev/null +++ b/crates/workshop-server/src/gateway_binding.rs @@ -0,0 +1,267 @@ +//! 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, + /// 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, +} + +impl fmt::Debug for GatewaySnapshot { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("GatewaySnapshot") + .field("client", &self.client) + .field("model_client", &"") + .field("generation", &self.generation) + .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.client.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. + pub(crate) fn new(base_url: &str, api_key: &str) -> Result { + let snapshot = Arc::new(build_snapshot(base_url, api_key, 0)?); + 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 api_key = client.api_key.clone(); + let snapshot = Arc::new(GatewaySnapshot { + client, + api_key, + model_client, + generation: 0, + }); + 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. + pub(crate) fn replace(&self, base_url: &str, api_key: &str) -> 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)?); + 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 local-sidecar connection file. +#[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::InvalidSidecar`] if the file is structurally + /// invalid, or [`GatewayError::Build`] if the replacement HTTP client + /// cannot initialize. + pub fn replace_sidecar( + &self, + file: &shared_sidecar::ConnectionFile, + ) -> Result<(), GatewayError> { + if let Some(reason) = file.validation_error() { + return Err(GatewayError::InvalidSidecar { reason }); + } + self.binding + .replace(&format!("http://127.0.0.1:{}", file.port), &file.api_key) + } +} + +/// Builds all clients before publication so URL and bearer never tear. +fn build_snapshot( + base_url: &str, + api_key: &str, + generation: u64, +) -> Result { + let client = GatewayClient::new(base_url, api_key)?; + let model_client = model_client(base_url, api_key); + Ok(GatewaySnapshot { + client, + api_key: api_key.to_owned(), + model_client, + generation, + }) +} + +/// 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 { + use super::*; + + #[test] + fn endpoint_and_credential_replace_as_one_snapshot() { + let binding = + GatewayBinding::new("http://127.0.0.1:54375", "old-key").expect("binding builds"); + let old = binding.snapshot(); + + binding + .replace("http://127.0.0.1:54379", "new-key") + .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!(new.client().base_url, "http://127.0.0.1:54379"); + assert_eq!(new.client().api_key, "new-key"); + assert!(new.generation() > old.generation()); + assert_eq!(binding.generation(), new.generation()); + } + + #[test] + fn updater_rejects_an_invalid_connection_file_before_publication() { + let binding = + GatewayBinding::new("http://127.0.0.1:54375", "old-key").expect("binding builds"); + let before = binding.snapshot(); + let error = binding + .updater() + .replace_sidecar(&shared_sidecar::ConnectionFile { + port: 0, + api_key: "new-key".to_owned(), + pid: 7, + epoch: 0, + version: "test".to_owned(), + started_at: String::new(), + }) + .expect_err("an invalid connection file is refused"); + assert!(matches!(error, GatewayError::InvalidSidecar { .. })); + assert_eq!(binding.generation(), before.generation()); + } +} diff --git a/crates/workshop-server/src/gateway_progress.rs b/crates/workshop-server/src/gateway_progress.rs index 7d52de90..a303352d 100644 --- a/crates/workshop-server/src/gateway_progress.rs +++ b/crates/workshop-server/src/gateway_progress.rs @@ -26,6 +26,7 @@ use tokio::sync::oneshot; use promptforge_model_client::model::subscribe_progress; use shared_progress::{EventState, OperationId, ProgressHub, RemoteOperation}; +use crate::gateway_binding::GatewayBinding; use crate::heartbeat::GatewayHealth; /// How long a resubscribe waits when the stream ended while the gateway @@ -62,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), @@ -103,18 +94,23 @@ fn spawn_with_delay( /// 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. @@ -124,16 +120,28 @@ 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; @@ -146,6 +154,12 @@ async fn run( tokio::select! { _ = &mut *stop => return, _ = reachable.changed() => break, + changed = gateway_changed.changed() => { + if changed.is_err() { + return; + } + continue 'reconnect; + } item = stream.next() => match item { Some(Ok(event)) => { let operation = event.operation; @@ -172,6 +186,11 @@ async fn run( tokio::select! { _ = &mut *stop => return, _ = reachable.changed() => {} + changed = gateway_changed.changed() => { + if changed.is_err() { + return; + } + } () = tokio::time::sleep(resubscribe_delay) => {} } } @@ -196,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 @@ -314,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( @@ -345,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( @@ -383,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, @@ -417,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; @@ -440,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( @@ -479,4 +492,5 @@ mod tests { } 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 index dc351bd3..8540f71a 100644 --- a/crates/workshop-server/src/gateway_progress/tests/lifecycle.rs +++ b/crates/workshop-server/src/gateway_progress/tests/lifecycle.rs @@ -15,12 +15,7 @@ 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( - 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( 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 d2fc8aa2..eeee63ef 100644 --- a/crates/workshop-server/src/heartbeat.rs +++ b/crates/workshop-server/src/heartbeat.rs @@ -33,11 +33,15 @@ use std::time::Duration; use tokio::sync::{oneshot, watch}; use crate::backoff::ReconnectBackoff; -use crate::catalog::is_chat_capable; +#[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. @@ -163,8 +167,8 @@ impl Heartbeat { /// 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, @@ -172,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), @@ -189,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, @@ -198,9 +209,8 @@ async fn run( stop: &mut oneshot::Receiver<()>, ) { let mut last: Option = None; - let mut profiles_ready = false; - let mut catalog_ready = false; - let mut selection_restored = false; + 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 { @@ -218,13 +228,36 @@ 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, }; + if gateway.generation() != generation { + last = None; + refresh = RefreshState::default(); + continue; + } health.publish(reachable); let transitioned = last != Some(reachable); last = Some(reachable); @@ -247,12 +280,10 @@ async fn run( } } if !reachable { - profiles_ready = false; - catalog_ready = false; - selection_restored = false; + refresh = RefreshState::default(); continue; } - if !profiles_ready || !catalog_ready { + 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, @@ -263,148 +294,49 @@ async fn run( // retries and keeps this from becoming a busy loop. tokio::select! { _ = &mut *stop => break, - () = async { - match (profiles_ready, catalog_ready) { - (false, false) => { - (profiles_ready, catalog_ready) = - tokio::join!(refresh_profiles(client, push), refresh_catalog(client, push)); - } - (false, true) => profiles_ready = refresh_profiles(client, push).await, - (true, false) => catalog_ready = refresh_catalog(client, push).await, - (true, true) => {} + changed = gateway_changed.changed() => { + if changed.is_err() { + break; } - } => {} + last = None; + refresh = RefreshState::default(); + continue; + } + () = refresh_incomplete_sources( + &snapshot, + push, + &mut refresh, + ) => {} } } - if profiles_ready && catalog_ready && !selection_restored { + if refresh.profiles_ready && refresh.catalog_ready && !refresh.selection_restored { // A fresh boot has no selection, so restore the remembered // model for the now-known active profile (else the first // catalog model); a reconnect whose selection survived the // outage is a no-op. This branch runs exactly once per reachable // convergence because both readiness facts remain true. push.menu().restore_selection(); - selection_restored = true; - } - } -} - -/// 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) -> 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 (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) -> 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 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) => {} } } @@ -577,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, @@ -662,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, @@ -907,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 @@ -918,7 +850,7 @@ mod tests { Duration::from_millis(50), ); let heartbeat = spawn( - client, + gateway, Push::new(status.clone(), catalog, menu), health.clone(), TEST_INTERVAL, @@ -945,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), @@ -960,5 +893,6 @@ mod tests { .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/lib.rs b/crates/workshop-server/src/lib.rs index 239e5f57..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; @@ -58,6 +59,31 @@ 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 { @@ -74,6 +100,7 @@ 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}; 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/routes/gateway_config.rs b/crates/workshop-server/src/routes/gateway_config.rs index d6fef18f..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, diff --git a/crates/workshop-server/src/routes/gateway_config/tests.rs b/crates/workshop-server/src/routes/gateway_config/tests.rs index 3d629ce7..da15fa5c 100644 --- a/crates/workshop-server/src/routes/gateway_config/tests.rs +++ b/crates/workshop-server/src/routes/gateway_config/tests.rs @@ -9,6 +9,8 @@ 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 [ 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 index e62fb283..288c0b97 100644 --- a/crates/workshop-server/src/routes/realtime.rs +++ b/crates/workshop-server/src/routes/realtime.rs @@ -37,7 +37,8 @@ async fn upgrade( if ws.requested_protocols().next().is_some() { return StatusCode::BAD_REQUEST.into_response(); } - match state.gateway_client().connect_realtime().await { + 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"); diff --git a/crates/workshop-server/src/serve.rs b/crates/workshop-server/src/serve.rs index b9cff3f7..c2419866 100644 --- a/crates/workshop-server/src/serve.rs +++ b/crates/workshop-server/src/serve.rs @@ -16,6 +16,7 @@ use std::time::Duration; use crate::app::{StateError, router, state_with_gateway}; use crate::config::Config; +use crate::gateway_binding::GatewayUpdater; use crate::gateway_progress; use crate::heartbeat; use crate::progress; @@ -52,6 +53,7 @@ pub enum Termination { #[derive(Debug)] pub struct ServerHandle { url: String, + gateway: GatewayUpdater, shutdown: Option>, stopped: mpsc::Receiver, thread: Option>>, @@ -65,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. /// @@ -182,8 +191,9 @@ fn spawn_inner( .name("workshop-server".to_string()) .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), @@ -211,7 +221,7 @@ fn spawn_inner( fn serve_thread( config: Config, gateway: ResolvedGateway, - ready: mpsc::Sender>, + ready: mpsc::Sender>, shutdown: tokio::sync::oneshot::Receiver<()>, stopped: &mpsc::Sender, grace: Duration, @@ -227,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) => { @@ -248,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, @@ -261,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 167eea7c..abfef8e2 100644 --- a/crates/workshop-server/src/session_agents.rs +++ b/crates/workshop-server/src/session_agents.rs @@ -40,14 +40,15 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use promptforge_core_support::cancel::CancelHandle; use promptforge_core_support::events::{CallMetrics, RuntimeEventKind, ToolCallEvent}; use promptforge_core_support::observe::{Observation, Observer}; -use promptforge_model_client::client::{ - 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 tokio::sync::{Notify, broadcast}; use crate::backoff::ReconnectBackoff; use crate::catalog::{CatalogBus, is_chat_capable}; +use crate::gateway_binding::GatewayBinding; use crate::input::{WaitError, WaitRegistry, deliver_input_response_before_completion}; use crate::menu::MenuBus; use crate::observer::WorkshopObserver; @@ -139,11 +140,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. @@ -169,14 +167,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()), }), @@ -220,9 +218,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) @@ -257,7 +255,7 @@ impl AgentSessions { Arc::clone(&session), self.clone(), self.inner.host.clone(), - client, + self.inner.gateway.clone(), ); Ok(session) } @@ -455,6 +453,11 @@ impl AgentSession { self.lifecycle.cancel_for_catalog() } + /// Retires the current run immediately after a Gateway replacement. + fn cancel_for_gateway(&self) { + self.lifecycle.cancel(CancelOrigin::Gateway); + } + /// Waits until the accepted turn reaches a terminal event. async fn wait_until_turn_settled(&self) { self.lifecycle.wait_until_settled().await; @@ -721,23 +724,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: @@ -981,7 +970,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(), diff --git a/crates/workshop-server/src/session_agents/lifecycle.rs b/crates/workshop-server/src/session_agents/lifecycle.rs index 37a1bc85..a479843c 100644 --- a/crates/workshop-server/src/session_agents/lifecycle.rs +++ b/crates/workshop-server/src/session_agents/lifecycle.rs @@ -12,6 +12,8 @@ pub(super) enum CancelOrigin { Operator, /// The supervisor retired an idle run for a new catalog generation. Catalog, + /// The desktop host published a relaunched local Gateway generation. + Gateway, } /// State shared by input acceptance, the supervisor, and terminal events. diff --git a/crates/workshop-server/src/session_agents/supervisor.rs b/crates/workshop-server/src/session_agents/supervisor.rs index 3f74d74b..e0d6c5b9 100644 --- a/crates/workshop-server/src/session_agents/supervisor.rs +++ b/crates/workshop-server/src/session_agents/supervisor.rs @@ -5,11 +5,10 @@ use std::sync::atomic::Ordering; use promptforge_agent::{AgentConfig, AgentError, AgentLimits, run_agent_with_client}; use promptforge_core_support::observe::Observer; -use promptforge_model_client::client::GatewayClient as ModelClient; use promptforge_store::StoreRef; use promptforge_tools::{Tool, ToolCatalog}; -use crate::catalog::{CatalogBus, ChatCatalog}; +use crate::gateway_binding::GatewayBinding; use crate::input::UserInputTool; use crate::protocol::Activity; @@ -18,6 +17,9 @@ use super::{ delta_stamp, ui_provider, }; +mod catalog; +use catalog::{wait_for_chat_catalog, wait_for_replacement_catalog}; + /// Spawns one session supervisor. Each run freezes one usable chat /// catalog; cancellation or a genuinely new usable generation relaunches /// over the retained event log. @@ -25,7 +27,7 @@ pub(super) fn spawn( session: Arc, registry: AgentSessions, host: SessionHost, - client: ModelClient, + gateway: GatewayBinding, ) { tokio::spawn(async move { let tool: Arc = Arc::new(UserInputTool::new( @@ -45,6 +47,7 @@ pub(super) fn spawn( let on_delta = delta_stamp(&session, &host.push); let ui = ui_provider(&host.menu, &host.workspace); let mut catalog_generation = host.catalog.subscribe_chat_generation(); + let mut gateway_generation = gateway.subscribe(); loop { let Some(chat_catalog) = wait_for_chat_catalog(&session, &host.catalog, &mut catalog_generation).await @@ -54,6 +57,15 @@ pub(super) fn spawn( let active_generation = chat_catalog.generation; let active_models = chat_catalog.models; let models = build_model_catalog(Some(active_models.clone())); + let gateway_snapshot = gateway.snapshot(); + let active_gateway_generation = gateway_snapshot.generation(); + let Some(client) = gateway_snapshot.model_client() else { + let message = "the replacement Gateway credentials cannot make a model client"; + let _ = session.errors.send(message.to_owned()); + host.push + .push_failure("Agent failed", message, Activity::General); + break; + }; let run_cancel = session.arm_cancel(); let config = AgentConfig { name: session.agent.clone(), @@ -96,33 +108,51 @@ pub(super) fn spawn( } } } - }; - match (result, session.cancel_origin()) { - (Err(AgentError::Interrupted), _) => { - if session.closing.load(Ordering::SeqCst) { - break; + replaced = wait_for_gateway_replacement( + &mut gateway_generation, + active_gateway_generation, + ) => { + if replaced { + session.cancel_for_gateway(); } - report_cancel_origin(&session); - } - (Ok(()), _) => break, - (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); - break; + run.await } + }; + if run_finished(result, &session, &host) { + break; } } registry.forget(&session.id); }); } +/// Reports one run ending and answers whether the supervisor is finished. +fn run_finished( + result: Result<(), AgentError>, + session: &AgentSession, + host: &SessionHost, +) -> bool { + match (result, session.cancel_origin()) { + (Err(AgentError::Interrupted), _) if !session.closing.load(Ordering::SeqCst) => { + report_cancel_origin(session); + false + } + (Err(AgentError::Interrupted) | Ok(()), _) => true, + (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); + true + } + } +} + /// Builds the observer shared by every generation of one session. fn observer(session: &AgentSession, host: &SessionHost) -> Arc { Arc::new(SessionObserver { @@ -143,6 +173,10 @@ fn report_cancel_origin(session: &AgentSession) { session = %session.id, "agent run retired for a new catalog generation" ), + Some(CancelOrigin::Gateway) => tracing::debug!( + session = %session.id, + "agent run retired for a new gateway generation" + ), None => tracing::debug!( session = %session.id, "agent run interrupted without a supervisor cancellation origin" @@ -150,50 +184,17 @@ fn report_cancel_origin(session: &AgentSession) { } } -/// Waits for the first non-empty chat catalog or session close. -async fn wait_for_chat_catalog( - session: &AgentSession, - catalog: &CatalogBus, +/// Waits until the host publishes a different Gateway generation. +async fn wait_for_gateway_replacement( generation: &mut tokio::sync::watch::Receiver, -) -> Option { + active: u64, +) -> bool { loop { - let closed = session.closed.notified(); - tokio::pin!(closed); - if session.closing.load(Ordering::SeqCst) { - return None; - } - if let Some(chat) = catalog.latest_chat() { - return Some(chat); - } - tokio::select! { - () = &mut closed => {} - changed = generation.changed() => { - if changed.is_err() { - return None; - } - } + if *generation.borrow_and_update() != active { + return true; } - } -} - -/// Waits for a usable generation with bindings different from this run. -/// Empty snapshots let an accepted dispatch report binding loss, while -/// restoring identical bindings needs no relaunch. -async fn wait_for_replacement_catalog( - catalog: &CatalogBus, - generation: &mut tokio::sync::watch::Receiver, - active_generation: u64, - active_models: &[serde_json::Value], -) -> Option { - loop { if generation.changed().await.is_err() { - return None; - } - if let Some(chat) = catalog.latest_chat() - && chat.generation != active_generation - && chat.models != active_models - { - return Some(chat); + return false; } } } 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..a6bde3dd --- /dev/null +++ b/crates/workshop-server/src/session_agents/supervisor/catalog.rs @@ -0,0 +1,53 @@ +//! Catalog-generation waits for one agent supervisor. + +use std::sync::atomic::Ordering; + +use crate::catalog::{CatalogBus, ChatCatalog}; + +use super::super::AgentSession; + +/// Waits for the first non-empty chat catalog or session close. +pub(super) async fn wait_for_chat_catalog( + session: &AgentSession, + catalog: &CatalogBus, + generation: &mut tokio::sync::watch::Receiver, +) -> Option { + loop { + let closed = session.closed.notified(); + tokio::pin!(closed); + if session.closing.load(Ordering::SeqCst) { + return None; + } + if let Some(chat) = catalog.latest_chat() { + return Some(chat); + } + tokio::select! { + () = &mut closed => {} + changed = generation.changed() => { + if changed.is_err() { + return None; + } + } + } + } +} + +/// Waits for a usable generation with bindings different from this run. +pub(super) async fn wait_for_replacement_catalog( + catalog: &CatalogBus, + generation: &mut tokio::sync::watch::Receiver, + active_generation: u64, + active_models: &[serde_json::Value], +) -> Option { + loop { + if generation.changed().await.is_err() { + return None; + } + if let Some(chat) = catalog.latest_chat() + && chat.generation != active_generation + && chat.models != active_models + { + return Some(chat); + } + } +} diff --git a/crates/workshop-server/tests/common/mod.rs b/crates/workshop-server/tests/common/mod.rs index cd0686d1..3ad4c1b0 100644 --- a/crates/workshop-server/tests/common/mod.rs +++ b/crates/workshop-server/tests/common/mod.rs @@ -81,6 +81,29 @@ impl TestServer { .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(), + }; + self.handle + .as_ref() + .expect("the handle is held until drop") + .gateway_updater() + .replace_sidecar(&file) + .expect("the replacement endpoint publishes"); + } } impl Drop for TestServer { diff --git a/crates/workshop-server/tests/it/chat_gate.rs b/crates/workshop-server/tests/it/chat_gate.rs index 7c029c41..23da1d2d 100644 --- a/crates/workshop-server/tests/it/chat_gate.rs +++ b/crates/workshop-server/tests/it/chat_gate.rs @@ -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, @@ -407,6 +407,74 @@ async fn gate_streaming_delivers_text_and_reasoning_deltas_then_the_reply() { socket.close().await; } +#[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"); + gateway_updater(&server.state) + .replace_sidecar(&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 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 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. diff --git a/crates/workshop-server/tests/it/realtime_relay.rs b/crates/workshop-server/tests/it/realtime_relay.rs index 3321590c..5a56ee39 100644 --- a/crates/workshop-server/tests/it/realtime_relay.rs +++ b/crates/workshop-server/tests/it/realtime_relay.rs @@ -561,6 +561,60 @@ async fn browser_disconnect_releases_the_gateway_peer() { .expect("an abrupt browser disconnect closes the Gateway hop"); } +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; + } + } + }) +} + +#[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" + ); +} + fn request_with( url: &str, origin: Option<&str>, diff --git a/crates/workshop-server/ui/src/ui/prompt-input.ts b/crates/workshop-server/ui/src/ui/prompt-input.ts index 8cf7ed29..89e48cb1 100644 --- a/crates/workshop-server/ui/src/ui/prompt-input.ts +++ b/crates/workshop-server/ui/src/ui/prompt-input.ts @@ -217,6 +217,11 @@ export class PromptInput extends Disposable implements SttInputTarget { return { start: from, end: to }; } + /** The logical document end in ProseMirror's position space. */ + getDocumentEnd(): number { + return this.editor.state.doc.content.size - 1; + } + /** Places the cursor or selection at ProseMirror positions. */ setSelection(from: number, to: number): void { this.editor.commands.setTextSelection({ from, to }); diff --git a/crates/workshop-server/ui/src/ui/realtime-stt.ts b/crates/workshop-server/ui/src/ui/realtime-stt.ts index ec27c29d..29333e0e 100644 --- a/crates/workshop-server/ui/src/ui/realtime-stt.ts +++ b/crates/workshop-server/ui/src/ui/realtime-stt.ts @@ -20,6 +20,7 @@ interface Take { from: number; length: number; readonly original: string; + readonly compositionPrefix: "" | " "; itemId: string | null; } @@ -85,6 +86,14 @@ export function setupStt( input.setReadOnly(takes.length > 0); } + function composeTranscript(take: Take, transcript: string): string { + return take.compositionPrefix !== "" && + transcript !== "" && + !/^\s/.test(transcript) + ? take.compositionPrefix + transcript + : transcript; + } + function splice(take: Take, text: string): void { const oldEnd = take.from + take.length; const delta = text.length - take.length; @@ -153,7 +162,7 @@ export function setupStt( take.itemId = snapshot.itemId; byItem.set(snapshot.itemId, take); } - splice(take, snapshot.text); + splice(take, composeTranscript(take, snapshot.text)); } function applyCompletion(completion: RealtimeTranscriptCompletion): void { @@ -166,16 +175,8 @@ export function setupStt( void releaseCapture(); setRecording(false); } - const current = input.readRange(take.from, take.from + take.length); - const insertionWhitespace = current.match(/^\s+/)?.[0] ?? ""; const authoritative = completion.transcript.trimEnd(); - const transcript = - take.original === "" && - authoritative !== "" && - insertionWhitespace !== "" && - !/^\s/.test(authoritative) - ? insertionWhitespace + authoritative - : authoritative; + const transcript = composeTranscript(take, authoritative); splice(take, transcript); removeTake(take); if (transcript === "") { @@ -312,6 +313,12 @@ export function setupStt( from: selection.start, length: selection.end - selection.start, original: input.readRange(selection.start, selection.end), + compositionPrefix: + selection.start === selection.end && + selection.end === input.getDocumentEnd() && + /\S$/.test(input.readRange(0, selection.start)) + ? " " + : "", itemId: null, }; takes.push(take); diff --git a/crates/workshop-server/ui/src/ui/stt.ts b/crates/workshop-server/ui/src/ui/stt.ts index 3ce83e51..b31b3d83 100644 --- a/crates/workshop-server/ui/src/ui/stt.ts +++ b/crates/workshop-server/ui/src/ui/stt.ts @@ -16,6 +16,8 @@ export { setupStt } from "./realtime-stt"; export interface SttInputTarget { /** The current selection: the take's insertion anchor. */ getSelection(): { start: number; end: number }; + /** The logical document-end position in the target's coordinate space. */ + getDocumentEnd(): number; /** Replaces [from, to] with text, leaving the cursor after the inserted text. */ replaceRange(from: number, to: number, text: string): void; /** Reads the plain text currently occupying [from, to]. */ @@ -41,6 +43,7 @@ export function textareaSttTarget(input: HTMLTextAreaElement): SttInputTarget { start: input.selectionStart ?? input.value.length, end: input.selectionEnd ?? input.value.length, }), + getDocumentEnd: () => input.value.length, replaceRange: (from, to, text) => { input.setRangeText(text, from, to, "end"); // Programmatic value sets don't fire the textarea's "input" event, diff --git a/crates/workshop-server/ui/test/agent-stt.mjs b/crates/workshop-server/ui/test/agent-stt.mjs index b102a8bb..c010c99a 100644 --- a/crates/workshop-server/ui/test/agent-stt.mjs +++ b/crates/workshop-server/ui/test/agent-stt.mjs @@ -36,6 +36,42 @@ function canonicalMessage(sequence, direction, type, occurrence = 0) { ); } +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: { contents: ` @@ -701,6 +737,137 @@ await assertNoLeaks(lifecycle, async () => { 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(); + } + // --- Takes insert at the cursor ------------------------------------------- { diff --git a/crates/workshop/src/gateway.rs b/crates/workshop/src/gateway.rs index 99183b4e..c293d8fa 100644 --- a/crates/workshop/src/gateway.rs +++ b/crates/workshop/src/gateway.rs @@ -18,6 +18,7 @@ //! (`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 _; @@ -39,6 +40,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,26 +218,40 @@ 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 bare @@ -247,6 +299,130 @@ 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| { + updater + .replace_sidecar(file) + .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::*; @@ -479,8 +655,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"); } @@ -488,14 +665,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"); @@ -525,4 +719,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 index 2d71d467..c504c514 100644 --- a/design/generic-realtime-stt-acceptance.md +++ b/design/generic-realtime-stt-acceptance.md @@ -2,17 +2,302 @@ ## Status -Accepted by the operator for the installed unsigned package built from current HEAD `2d1ecca8`. The applicable Gateway hashes match, and the operator's authoritative verdict for this build is `Works correctly. Accepted.` Prior attempts remain below as history and do not supersede this verdict. +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. -- Acceptance gate: passed by authoritative operator verdict -- Current automated installed-package boundary: passed -- Current operator boundary: passed for the repaired live transcription and short-utterance Stop behavior recorded below +- 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` -- Current Workshop process ID: 68872 -- Current Gateway process ID: 91128 -- Signing: not tested +- 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. @@ -629,7 +914,7 @@ The Workshop build-tree executable has the same size but SHA-256 `169639C71AD940 ## Operator observation checklist retained for audit context -This checklist records the originally requested observation detail. Unchecked items were not individually recorded and are not retroactively claimed as measured; the operator's later authoritative verdict for the identified installed build is the acceptance decision. +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. @@ -643,7 +928,7 @@ This checklist records the originally requested observation detail. Unchecked it - [ ] 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 latest installed build and accepted it as recorded below, without supplying measurements or item-by-item results beyond those stated. +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 @@ -680,3 +965,176 @@ Automated preparation did not perform the checklist. The operator later observed - 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/vibe/2026-09-05-2-generic-realtime-stt.md b/vibe/2026-09-05-2-generic-realtime-stt.md index 053fb2f3..0e0e5d4d 100644 --- a/vibe/2026-09-05-2-generic-realtime-stt.md +++ b/vibe/2026-09-05-2-generic-realtime-stt.md @@ -827,10 +827,10 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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 +### 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`; change no implementation. -- Scope: run every exit criterion independently under PowerShell 5.1 and stop on any failure. +- 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` @@ -861,6 +861,6 @@ The architecture harness enforces exact workspace-package edges across normal, d - `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. Completion requires every command, ratchet, generated-doc check, and physical scenario to pass with no open finding. +- 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/archdoc-next.md b/vibe/archdoc-next.md index 97a79204..a3c6baee 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -182,7 +182,7 @@ N53 | observation | surface-growth @ crates/workshop-server/ui/src/services/real 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 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 +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 N60 | observation | Violates A2 @ crates/gateway-stt/src/take: credential ownership is not determinable from diff | Reconcile explicitly skipped final ranges From 5c80bbd8685f12378eed012290727e6195abb842 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 08:46:44 -0700 Subject: [PATCH 53/86] Close plan: generic Realtime STT Plan: vibe/2026-09-05-2-generic-realtime-stt.md --- vibe/ACTIVE | 1 - 1 file changed, 1 deletion(-) delete mode 100644 vibe/ACTIVE diff --git a/vibe/ACTIVE b/vibe/ACTIVE deleted file mode 100644 index 42299ab6..00000000 --- a/vibe/ACTIVE +++ /dev/null @@ -1 +0,0 @@ -2026-09-05-2-generic-realtime-stt From f434c4175af5af357bac38fa62de1a69cbab045e Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 09:15:15 -0700 Subject: [PATCH 54/86] Split Gateway Realtime integration coverage Split Gateway Realtime integration coverage into six concern-focused files while retaining shared support in the parent module. Preserve all 18 tests, including the ignored native case, with unchanged test bodies. Seed and activate the attributable debt-removal plan for the remaining work. - `crates/gateway/tests/it/realtime_stt.rs` keeps shared fixtures and uses `include!` to assemble the authentication, protocol, lifecycle, recovery, overload, and canonical sequence coverage in one module scope. - `vibe/2026-09-07-1-promptforge-debt.md` records the debt program and marks `Step 1` complete, while `vibe/ACTIVE` selects it for continued execution. Design: new oversized-unit @ crates/gateway/tests/it/realtime_stt/authentication.rs Design: new oversized-unit @ crates/gateway/tests/it/realtime_stt/authentication.rs::gateway_auth_origin_query_and_final_speech_surfaces_precede_upgrade Design: new oversized-unit @ crates/gateway/tests/it/realtime_stt/canonical_sequence.rs Design: replaces oversized-unit @ crates/gateway/tests/it/realtime_stt/canonical_sequence.rs::canonical_fixture_drives_hypothesis_completion_and_clear was: crates/gateway/tests/it/realtime_stt.rs::canonical_fixture_drives_hypothesis_completion_and_clear Design: new oversized-unit @ crates/gateway/tests/it/realtime_stt/lifecycle.rs Design: replaces oversized-unit @ crates/gateway/tests/it/realtime_stt/lifecycle.rs::interim_scheduler_enforces_cadence_minimum_silence_and_coalescing was: crates/gateway/tests/it/realtime_stt.rs::interim_scheduler_enforces_cadence_minimum_silence_and_coalescing Design: new oversized-unit @ crates/gateway/tests/it/realtime_stt/overload.rs Design: replaces oversized-unit @ crates/gateway/tests/it/realtime_stt/overload.rs::saturated_commit_preserves_the_canonical_input_for_retry was: crates/gateway/tests/it/realtime_stt.rs::saturated_commit_preserves_the_canonical_input_for_retry Design: new oversized-unit @ crates/gateway/tests/it/realtime_stt/protocol.rs Design: new oversized-unit @ crates/gateway/tests/it/realtime_stt/protocol.rs::mounted_route_drives_scripted_wire_ownership_errors_and_privacy Design: new oversized-unit @ crates/gateway/tests/it/realtime_stt/protocol.rs::mounted_session_errors_keep_canonical_codes_parameters_and_correlation Design: new oversized-unit @ crates/gateway/tests/it/realtime_stt/recovery.rs Design: new oversized-unit @ crates/gateway/tests/it/realtime_stt/recovery.rs::admission_is_bounded_and_replacement_closes_with_1012 Plan: vibe/2026-09-07-1-promptforge-debt.md --- crates/gateway/tests/it/realtime_stt.rs | 1131 +---------------- .../tests/it/realtime_stt/authentication.rs | 89 ++ .../it/realtime_stt/canonical_sequence.rs | 102 ++ .../tests/it/realtime_stt/lifecycle.rs | 227 ++++ .../gateway/tests/it/realtime_stt/overload.rs | 162 +++ .../gateway/tests/it/realtime_stt/protocol.rs | 377 ++++++ .../gateway/tests/it/realtime_stt/recovery.rs | 151 +++ vibe/2026-09-07-1-promptforge-debt.md | 542 ++++++++ vibe/ACTIVE | 1 + 9 files changed, 1657 insertions(+), 1125 deletions(-) create mode 100644 crates/gateway/tests/it/realtime_stt/authentication.rs create mode 100644 crates/gateway/tests/it/realtime_stt/canonical_sequence.rs create mode 100644 crates/gateway/tests/it/realtime_stt/lifecycle.rs create mode 100644 crates/gateway/tests/it/realtime_stt/overload.rs create mode 100644 crates/gateway/tests/it/realtime_stt/protocol.rs create mode 100644 crates/gateway/tests/it/realtime_stt/recovery.rs create mode 100644 vibe/2026-09-07-1-promptforge-debt.md create mode 100644 vibe/ACTIVE diff --git a/crates/gateway/tests/it/realtime_stt.rs b/crates/gateway/tests/it/realtime_stt.rs index 067ddee1..387b0343 100644 --- a/crates/gateway/tests/it/realtime_stt.rs +++ b/crates/gateway/tests/it/realtime_stt.rs @@ -330,387 +330,6 @@ async fn expect_error( event } -#[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.park_next(); - append_audio(&mut socket, audio()).await; - let parked = interim.clone(); - assert!( - tokio::task::spawn_blocking(move || parked.wait_until_parked(PHASE_TIMEOUT)) - .await - .expect("park observer joins"), - "the first eligible scheduled decode parks" - ); - for _ in 0..5 { - append_audio(&mut 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" - ); - - interim.release(); - 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.park_next(); - for _ in 0..5 { - append_audio(&mut socket, audio()).await; - } - let canceled = interim.clone(); - assert!( - tokio::task::spawn_blocking(move || canceled.wait_until_parked(PHASE_TIMEOUT)) - .await - .expect("cancellation park observer joins") - ); - send( - &mut socket, - serde_json::json!({"type": "input_audio_buffer.clear"}), - ) - .await; - expect_type(&mut socket, "input_audio_buffer.cleared").await; - interim.release(); - 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; -} - -#[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.park_next(); - for _ in 0..5 { - append_audio(&mut socket, audio()).await; - } - let parked = interim.clone(); - assert!( - tokio::task::spawn_blocking(move || { - parked.wait_for_requests(request_count, PHASE_TIMEOUT) - && parked.wait_until_parked(PHASE_TIMEOUT) - }) - .await - .expect("park observer joins"), - "scheduled interim {request_count} reaches its worker" - ); - send( - &mut socket, - serde_json::json!({"type": "input_audio_buffer.clear"}), - ) - .await; - assert_eq!( - expect_type(&mut socket, "input_audio_buffer.cleared").await["type"], - "input_audio_buffer.cleared", - "completed canceled joins free bounded capacity before cycle {request_count}" - ); - interim.release(); - 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 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; -} - -#[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] -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.park_next(); - append_audio( - &mut socket, - audio_samples(&[vec![0; 72_000], vec![8_192; 12_000]].concat()), - ) - .await; - let parked = final_decoder.clone(); - assert!( - tokio::task::spawn_blocking(move || parked.wait_until_parked(PHASE_TIMEOUT)) - .await - .expect("finalization park observer joins") - ); - let pending = expect_type( - &mut socket, - "conversation.item.input_audio_transcription.hypothesis", - ) - .await; - assert_eq!(pending["transcript"], "first phrase second phrase"); - assert_eq!(pending["finalized"], ""); - - final_decoder.release(); - 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; -} - async fn assert_stop_reconciles_skipped_range(short_input_samples: usize) { let interim = ScriptedDecoder::new(); interim.push_text("last word"); @@ -796,16 +415,6 @@ async fn assert_stop_reconciles_skipped_range(short_input_samples: usize) { 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; -} - async fn assert_same_range_final_authority(final_text: &str, expected: &str) { let interim = ScriptedDecoder::new(); interim.push_text("provisional words"); @@ -863,88 +472,6 @@ async fn assert_same_range_final_authority(final_text: &str, expected: &str) { server.shutdown().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; -} - -#[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 path = native_fixture(variable, name); - assert!( - path.is_file(), - "native test fixture is missing: {}", - path.display() - ); - } - 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"); -} - fn assert_native_incremental_spans(spans: &[(u64, u64, String)]) { assert!( spans.windows(2).all(|pair| pair[0].1 < pair[1].1), @@ -1018,561 +545,6 @@ async fn assert_final_speech_route_surface(http: &reqwest::Client, address: Sock } } -#[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; -} - -#[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 admission_is_bounded_and_replacement_closes_with_1012() { - let interim = ScriptedDecoder::new(); - let final_decoder = ScriptedDecoder::new(); - final_decoder.park_next(); - 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); - } - assert_eq!( - rejected( - server.addr, - "intent=transcription", - Some("test-token"), - None - ) - .await, - 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; - } - 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; - let parked = final_decoder.clone(); - assert!( - tokio::task::spawn_blocking(move || parked.wait_until_parked(PHASE_TIMEOUT)) - .await - .expect("park observer joins"), - "committed item owns its final decode" - ); - - let replacement = ScriptedDecoder::new(); - let replacement_final = ScriptedDecoder::new(); - let replacement_service = service.clone(); - 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"); - drop(sockets); - final_decoder.release(); - - 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; -} - -#[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; -} - -#[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; - } -} - async fn commit_existing_item( socket: &mut Socket, append: &serde_json::Value, @@ -1679,100 +651,9 @@ async fn expect_retried_item( 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(); - final_decoder.park_next(); - 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 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); - } - let parked = final_decoder.clone(); - assert!( - tokio::task::spawn_blocking(move || parked.wait_until_parked(PHASE_TIMEOUT)) - .await - .expect("park observer joins"), - "four committed items remain outstanding behind the parked final worker" - ); - assert_eq!( - final_decoder.requests().len(), - 1, - "the serial final worker is parked while four items own finalization" - ); - - for _ in 0..5 { - send(&mut socket, append.clone()).await; - } - send(&mut socket, commit).await; - let saturated = expect_type(&mut socket, "error").await; - let requests_at_saturation = final_decoder.requests().len(); - final_decoder.release(); - 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; -} +include!("realtime_stt/authentication.rs"); +include!("realtime_stt/protocol.rs"); +include!("realtime_stt/lifecycle.rs"); +include!("realtime_stt/recovery.rs"); +include!("realtime_stt/overload.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/lifecycle.rs b/crates/gateway/tests/it/realtime_stt/lifecycle.rs new file mode 100644 index 00000000..bf7350bb --- /dev/null +++ b/crates/gateway/tests/it/realtime_stt/lifecycle.rs @@ -0,0 +1,227 @@ +#[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.park_next(); + append_audio(&mut socket, audio()).await; + let parked = interim.clone(); + assert!( + tokio::task::spawn_blocking(move || parked.wait_until_parked(PHASE_TIMEOUT)) + .await + .expect("park observer joins"), + "the first eligible scheduled decode parks" + ); + for _ in 0..5 { + append_audio(&mut 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" + ); + + interim.release(); + 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.park_next(); + for _ in 0..5 { + append_audio(&mut socket, audio()).await; + } + let canceled = interim.clone(); + assert!( + tokio::task::spawn_blocking(move || canceled.wait_until_parked(PHASE_TIMEOUT)) + .await + .expect("cancellation park observer joins") + ); + send( + &mut socket, + serde_json::json!({"type": "input_audio_buffer.clear"}), + ) + .await; + expect_type(&mut socket, "input_audio_buffer.cleared").await; + interim.release(); + 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; +} +#[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.park_next(); + for _ in 0..5 { + append_audio(&mut socket, audio()).await; + } + let parked = interim.clone(); + assert!( + tokio::task::spawn_blocking(move || { + parked.wait_for_requests(request_count, PHASE_TIMEOUT) + && parked.wait_until_parked(PHASE_TIMEOUT) + }) + .await + .expect("park observer joins"), + "scheduled interim {request_count} reaches its worker" + ); + send( + &mut socket, + serde_json::json!({"type": "input_audio_buffer.clear"}), + ) + .await; + assert_eq!( + expect_type(&mut socket, "input_audio_buffer.cleared").await["type"], + "input_audio_buffer.cleared", + "completed canceled joins free bounded capacity before cycle {request_count}" + ); + interim.release(); + 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.park_next(); + append_audio( + &mut socket, + audio_samples(&[vec![0; 72_000], vec![8_192; 12_000]].concat()), + ) + .await; + let parked = final_decoder.clone(); + assert!( + tokio::task::spawn_blocking(move || parked.wait_until_parked(PHASE_TIMEOUT)) + .await + .expect("finalization park observer joins") + ); + let pending = expect_type( + &mut socket, + "conversation.item.input_audio_transcription.hypothesis", + ) + .await; + assert_eq!(pending["transcript"], "first phrase second phrase"); + assert_eq!(pending["finalized"], ""); + + final_decoder.release(); + 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..6f3a1558 --- /dev/null +++ b/crates/gateway/tests/it/realtime_stt/overload.rs @@ -0,0 +1,162 @@ +#[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; + } +} +#[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(); + final_decoder.park_next(); + 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 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); + } + let parked = final_decoder.clone(); + assert!( + tokio::task::spawn_blocking(move || parked.wait_until_parked(PHASE_TIMEOUT)) + .await + .expect("park observer joins"), + "four committed items remain outstanding behind the parked final worker" + ); + assert_eq!( + final_decoder.requests().len(), + 1, + "the serial final worker is parked while four items own finalization" + ); + + for _ in 0..5 { + send(&mut socket, append.clone()).await; + } + send(&mut socket, commit).await; + let saturated = expect_type(&mut socket, "error").await; + let requests_at_saturation = final_decoder.requests().len(); + final_decoder.release(); + 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/protocol.rs b/crates/gateway/tests/it/realtime_stt/protocol.rs new file mode 100644 index 00000000..f700ca80 --- /dev/null +++ b/crates/gateway/tests/it/realtime_stt/protocol.rs @@ -0,0 +1,377 @@ +#[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 path = native_fixture(variable, name); + assert!( + path.is_file(), + "native test fixture is missing: {}", + path.display() + ); + } + 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..9b22a0c9 --- /dev/null +++ b/crates/gateway/tests/it/realtime_stt/recovery.rs @@ -0,0 +1,151 @@ +#[tokio::test] +async fn admission_is_bounded_and_replacement_closes_with_1012() { + let interim = ScriptedDecoder::new(); + let final_decoder = ScriptedDecoder::new(); + final_decoder.park_next(); + 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); + } + assert_eq!( + rejected( + server.addr, + "intent=transcription", + Some("test-token"), + None + ) + .await, + 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; + } + 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; + let parked = final_decoder.clone(); + assert!( + tokio::task::spawn_blocking(move || parked.wait_until_parked(PHASE_TIMEOUT)) + .await + .expect("park observer joins"), + "committed item owns its final decode" + ); + + let replacement = ScriptedDecoder::new(); + let replacement_final = ScriptedDecoder::new(); + let replacement_service = service.clone(); + 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"); + drop(sockets); + final_decoder.release(); + + 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/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md new file mode 100644 index 00000000..57ccdee4 --- /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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 + +- 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 From af5e64d4ede5c42947b919a917183bc13194cf5d Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 09:28:04 -0700 Subject: [PATCH 55/86] Split Workshop relay integration coverage Split Workshop chat and Realtime relay integration tests into concern-focused modules while each parent retains shared fixtures. Preserve all 11 chat tests and all 9 relay tests with unchanged test bodies. - `crates/workshop-server/tests/it/chat_gate.rs` uses `include!` to assemble protocol, lifecycle, recovery, overload, and canonical sequence coverage. - `crates/workshop-server/tests/it/realtime_relay.rs` uses `include!` to assemble authentication, protocol, lifecycle, recovery, overload, and canonical sequence coverage. - `crates/workshop-server/tests/it/chat_gate.rs` and `crates/workshop-server/tests/it/realtime_relay.rs` add no persistent count or physical-line ceiling. Design: new oversized-unit @ crates/workshop-server/tests/it/chat_gate/lifecycle.rs Design: replaces oversized-unit @ crates/workshop-server/tests/it/chat_gate/lifecycle.rs::gate_catalog_replacement_during_acceptance_settles_the_turn_exactly_once was: crates/workshop-server/tests/it/chat_gate.rs::gate_catalog_replacement_during_acceptance_settles_the_turn_exactly_once Design: new oversized-unit @ crates/workshop-server/tests/it/chat_gate/recovery.rs Design: replaces oversized-unit @ crates/workshop-server/tests/it/chat_gate/recovery.rs::gate_restart_reloads_the_jsonl_and_resumes_waiting_for_input was: crates/workshop-server/tests/it/chat_gate.rs::gate_restart_reloads_the_jsonl_and_resumes_waiting_for_input Design: replaces oversized-unit @ crates/workshop-server/tests/it/chat_gate/recovery.rs::gate_binding_loss_surfaces_one_error_and_recovers_after_selection was: crates/workshop-server/tests/it/chat_gate.rs::gate_binding_loss_surfaces_one_error_and_recovers_after_selection Design: new oversized-unit @ crates/workshop-server/tests/it/realtime_relay/authentication.rs Design: replaces oversized-unit @ crates/workshop-server/tests/it/realtime_relay/authentication.rs::realtime_relay_is_authenticated_fixed_and_payload_opaque boundary: wire was: crates/workshop-server/tests/it/realtime_relay.rs::realtime_relay_is_authenticated_fixed_and_payload_opaque Pending: N49 - compounds Pending: N50 - compounds Plan: vibe/2026-09-07-1-promptforge-debt.md --- crates/workshop-server/tests/it/chat_gate.rs | 737 +----------------- .../tests/it/chat_gate/canonical_sequence.rs | 51 ++ .../tests/it/chat_gate/lifecycle.rs | 276 +++++++ .../tests/it/chat_gate/overload.rs | 46 ++ .../tests/it/chat_gate/protocol.rs | 54 ++ .../tests/it/chat_gate/recovery.rs | 301 +++++++ .../tests/it/realtime_relay.rs | 321 +------- .../tests/it/realtime_relay/authentication.rs | 115 +++ .../it/realtime_relay/canonical_sequence.rs | 49 ++ .../tests/it/realtime_relay/lifecycle.rs | 27 + .../tests/it/realtime_relay/overload.rs | 27 + .../tests/it/realtime_relay/protocol.rs | 59 ++ .../tests/it/realtime_relay/recovery.rs | 33 + vibe/2026-09-07-1-promptforge-debt.md | 2 +- vibe/archdoc-next.md | 4 +- 15 files changed, 1052 insertions(+), 1050 deletions(-) create mode 100644 crates/workshop-server/tests/it/chat_gate/canonical_sequence.rs create mode 100644 crates/workshop-server/tests/it/chat_gate/lifecycle.rs create mode 100644 crates/workshop-server/tests/it/chat_gate/overload.rs create mode 100644 crates/workshop-server/tests/it/chat_gate/protocol.rs create mode 100644 crates/workshop-server/tests/it/chat_gate/recovery.rs create mode 100644 crates/workshop-server/tests/it/realtime_relay/authentication.rs create mode 100644 crates/workshop-server/tests/it/realtime_relay/canonical_sequence.rs create mode 100644 crates/workshop-server/tests/it/realtime_relay/lifecycle.rs create mode 100644 crates/workshop-server/tests/it/realtime_relay/overload.rs create mode 100644 crates/workshop-server/tests/it/realtime_relay/protocol.rs create mode 100644 crates/workshop-server/tests/it/realtime_relay/recovery.rs diff --git a/crates/workshop-server/tests/it/chat_gate.rs b/crates/workshop-server/tests/it/chat_gate.rs index 23da1d2d..51852d3d 100644 --- a/crates/workshop-server/tests/it/chat_gate.rs +++ b/crates/workshop-server/tests/it/chat_gate.rs @@ -300,224 +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; -} - -#[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"); - gateway_updater(&server.state) - .replace_sidecar(&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 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 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 { @@ -582,517 +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; -} - -/// 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; -} - -/// 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; -} +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..77ac171d --- /dev/null +++ b/crates/workshop-server/tests/it/chat_gate/recovery.rs @@ -0,0 +1,301 @@ +#[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"); + gateway_updater(&server.state) + .replace_sidecar(&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 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/realtime_relay.rs b/crates/workshop-server/tests/it/realtime_relay.rs index 5a56ee39..5f2e2c00 100644 --- a/crates/workshop-server/tests/it/realtime_relay.rs +++ b/crates/workshop-server/tests/it/realtime_relay.rs @@ -267,160 +267,6 @@ async fn assert_no_frame( ); } -#[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"); -} - -#[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 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" - ); -} - async fn upstream_close(ws: WebSocketUpgrade) -> Response { ws.on_upgrade(|mut socket| async move { let _ = socket @@ -464,103 +310,6 @@ async fn send_large_frame_then_disconnect( }) } -#[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 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" - ); -} - -#[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())) - ); -} - -#[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"); -} - async fn recovered_upstream(headers: HeaderMap, ws: WebSocketUpgrade) -> Response { let authorized = headers .get(header::AUTHORIZATION) @@ -581,40 +330,6 @@ async fn recovered_upstream(headers: HeaderMap, ws: WebSocketUpgrade) -> Respons }) } -#[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" - ); -} - fn request_with( url: &str, origin: Option<&str>, @@ -648,33 +363,9 @@ async fn rejected_status(request: tokio_tungstenite::tungstenite::http::Request< StatusCode::from_u16(response.status().as_u16()).expect("the status is standard") } -#[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 - ); -} +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/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index 57ccdee4..1df9b8a8 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -263,7 +263,7 @@ isProject: false - 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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index a3c6baee..c67827e1 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -174,8 +174,8 @@ N45 | observation | shared-parameter-cluster @ crates/gateway/src/lib.rs::reques 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 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 -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 +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 From cbd20b63b4658bba572d88bde8b4bb4703a01153 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 09:36:38 -0700 Subject: [PATCH 56/86] Enforce integration test suite ceilings Freeze the split integration suites behind exact source, include, size, and test-count contracts. Use syntax-aware Rust discovery so comments, literals, attributes, macros, and configuration gates cannot hide drift. - `checkIntegrationTestCeilings` normalizes repository paths, requires exact suite and file coverage, verifies every direct `include!` exactly once, enforces physical-line ceilings, and checks exact test totals. - `analyzeRust` tokenizes Rust syntax and fails closed on nested includes, generated tests, conditional tests, unsupported test attributes, and malformed delimiters or literals. - `tools/integration-test-ceilings.json` records 18 Gateway Realtime tests, 11 Workshop chat tests, and 9 Workshop relay tests with ceilings for every entry and concern file. - `tools/check-integration-test-ceilings.test.mjs` covers newline variants, path separators, comments, multiline attributes, configuration gates, macro generation, missing and extra files, include drift, ceiling overruns, and total drift. - `.github/workflows/ci.yml` runs the adversarial driver tests and the repository gate before architecture tool installation. Design: new pure-function @ tools/check-integration-test-ceilings.mjs::repoPath deps: value Design: new surface-growth @ tools/check-integration-test-ceilings.mjs::repoPath deps: value boundary: pub Design: new pure-function @ tools/check-integration-test-ceilings.mjs::physicalLineCount deps: source Design: new surface-growth @ tools/check-integration-test-ceilings.mjs::physicalLineCount deps: source boundary: pub Design: new oversized-unit @ tools/check-integration-test-ceilings.mjs::analyzeRust deps: label,source Design: new oversized-unit @ tools/check-integration-test-ceilings.mjs::checkIntegrationTestCeilings deps: manifest,requiredSuites,root Design: new surface-growth @ tools/check-integration-test-ceilings.mjs::checkIntegrationTestCeilings deps: manifest,requiredSuites,root boundary: pub Plan: vibe/2026-09-07-1-promptforge-debt.md --- .github/workflows/ci.yml | 6 + tools/check-integration-test-ceilings.mjs | 616 ++++++++++++++++++ .../check-integration-test-ceilings.test.mjs | 379 +++++++++++ tools/integration-test-ceilings.json | 49 ++ vibe/2026-09-07-1-promptforge-debt.md | 2 +- 5 files changed, 1051 insertions(+), 1 deletion(-) create mode 100644 tools/check-integration-test-ceilings.mjs create mode 100644 tools/check-integration-test-ceilings.test.mjs create mode 100644 tools/integration-test-ceilings.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1915e5f9..9da9dc3f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,12 @@ jobs: with: node-version: 22 + - 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 cargo install cargo-modules --version 0.25.0 --locked 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/integration-test-ceilings.json b/tools/integration-test-ceilings.json new file mode 100644 index 00000000..83cd5da0 --- /dev/null +++ b/tools/integration-test-ceilings.json @@ -0,0 +1,49 @@ +{ + "version": 1, + "suites": { + "crates/gateway/tests/it/realtime_stt": { + "testTotal": 18, + "entry": { + "path": "crates/gateway/tests/it/realtime_stt.rs", + "ceiling": 659 + }, + "files": { + "authentication.rs": 89, + "canonical_sequence.rs": 102, + "lifecycle.rs": 227, + "overload.rs": 162, + "protocol.rs": 377, + "recovery.rs": 151 + } + }, + "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/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index 1df9b8a8..268537a3 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -273,7 +273,7 @@ isProject: false - 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 +### 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. From 301286315a8036f243b6f90b0ecbedfdcfc4ffc5 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 09:58:36 -0700 Subject: [PATCH 57/86] Bound formatted logging records Apply one immutable budget set to formatted records and later queue, wait, shutdown, segment, and retention work. Use fixed-capacity buffers through formatting and text redaction, validate the complete input as UTF-8 even after retention stops, and keep only valid prefixes with an explicit truncation marker. - `LOG_LIMITS` centralizes six memory, latency, and disk budgets with compile-time relationships; only `max_formatted_record_bytes` takes effect in this change. - `LogEventWriter` replaces growable event storage with `BoundedBytes`, scans retained and discarded input through `Utf8Validator`, rejects invalid or incomplete UTF-8, and marks valid truncation with `TRUNCATION_MARKER`. - `RedactedLine` keeps every redaction buffer at the record capacity and preserves valid character boundaries when replacement text expands the result. - `LossCounts` combines eviction, truncation, and rejection in one pressure summary; rejected records count as dropped while retained truncated records count as affected. - `crates/gateway-logging/src/queue.rs` still reserves sequence before locking, blocks protected producers without a timeout, and reports loss only when empty; aggregate queued bytes, segment rotation, and broader redaction remain outside this change. Deferred: Enforce aggregate queued bytes and admission ordering. Deferred: Bound producer waits and shutdown. Deferred: Rotate log segments under the aggregate retention budget. Deferred: Expand structured and textual redaction coverage. Plan: vibe/2026-09-07-1-promptforge-debt.md --- crates/gateway-logging/src/config.rs | 37 +++ crates/gateway-logging/src/lib.rs | 55 +++- crates/gateway-logging/src/queue.rs | 93 +++++-- crates/gateway-logging/src/redact.rs | 153 +++++++++-- crates/gateway-logging/src/writer.rs | 369 +++++++++++++++++++++++++- vibe/2026-09-07-1-promptforge-debt.md | 2 +- 6 files changed, 653 insertions(+), 56 deletions(-) diff --git a/crates/gateway-logging/src/config.rs b/crates/gateway-logging/src/config.rs index be98e058..2fe01c13 100644 --- a/crates/gateway-logging/src/config.rs +++ b/crates/gateway-logging/src/config.rs @@ -1,12 +1,49 @@ //! The configuration input for [`LogRuntime::start`](crate::LogRuntime::start). use std::path::{Path, PathBuf}; +use std::time::Duration; /// Previous runs retained beside the current log: `gateway.log.1` (the /// newest rotation) through `gateway.log.5` (the oldest). A sixth /// previous run is deleted by the rotation that would create it. pub(crate) const RETAINED_RUNS: usize = 5; +/// 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_RUNS as u64 + 1) + ); +}; + /// The one input logging needs: the gateway state directory that holds /// `logs/`. /// diff --git a/crates/gateway-logging/src/lib.rs b/crates/gateway-logging/src/lib.rs index dec1d488..29d5d5f8 100644 --- a/crates/gateway-logging/src/lib.rs +++ b/crates/gateway-logging/src/lib.rs @@ -1,9 +1,10 @@ //! Bounded, prioritized file logging for the PromptForge gateway. //! -//! [`LogRuntime`] owns one worker thread that drains a fixed-capacity -//! priority queue into a rotated `gateway.log`; [`LogWriter`] adapts the -//! queue to `tracing-subscriber`'s `MakeWriter` so the binary's fmt layer -//! enqueues formatted events instead of blocking producer threads on disk. +//! [`LogRuntime`] owns one worker thread that drains a bounded priority +//! queue into a rotated `gateway.log`; [`LogWriter`] adapts the queue to +//! `tracing-subscriber`'s `MakeWriter` so the binary's fmt layer enqueues +//! byte-bounded formatted 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: @@ -26,3 +27,49 @@ pub use crate::writer::LogWriter; // 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 index c6c3c718..8c0080f9 100644 --- a/crates/gateway-logging/src/queue.rs +++ b/crates/gateway-logging/src/queue.rs @@ -80,6 +80,13 @@ pub(crate) struct Batch { 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 { @@ -94,7 +101,41 @@ struct State { lanes: [VecDeque; 5], len: usize, closed: bool, + loss: LossCounts, +} + +/// 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 take_summary(&mut self) -> Option> { + if self.is_empty() { + return None; + } + let dropped: u64 = self.evicted.iter().sum::() + self.rejected; + let affected = dropped + self.truncated; + 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) + } } impl State { @@ -108,7 +149,7 @@ impl State { 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.evicted[record.priority.lane()] += 1; + self.loss.evicted[record.priority.lane()] += 1; self.len -= 1; return Some(record); } @@ -140,22 +181,10 @@ impl State { } /// Builds the one synthetic summary of a pressure episode and resets - /// the counters; `None` when nothing was evicted since the last + /// the counters; `None` when no content was lost since the last /// summary. fn take_summary(&mut self) -> Option> { - if self.evicted.iter().all(|&count| count == 0) { - return None; - } - let total: u64 = self.evicted.iter().sum(); - let summary = format!( - "log pressure dropped {total} record(s): debug={}, trace={}, info={}\n", - self.evicted[LogPriority::Debug.lane()], - self.evicted[LogPriority::Trace.lane()], - self.evicted[LogPriority::Info.lane()], - ) - .into_boxed_str(); - self.evicted = [0; 5]; - Some(summary) + self.loss.take_summary() } } @@ -166,7 +195,7 @@ impl LogQueue { lanes: std::array::from_fn(|_| VecDeque::new()), len: 0, closed: false, - evicted: [0; 5], + loss: LossCounts::default(), }), work_available: Condvar::new(), space_available: Condvar::new(), @@ -180,7 +209,19 @@ impl LogQueue { /// with none eligible the producer blocks on the condition variable /// until the worker frees space. After [`close`](Self::close) 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, + ) { let sequence = self.next_sequence.fetch_add(1, Ordering::Relaxed); let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); loop { @@ -188,6 +229,9 @@ impl LogQueue { return; } if state.len < CAPACITY { + if status == FormatStatus::Truncated { + state.loss.truncated += 1; + } state.push(LogRecord { sequence, priority, @@ -198,6 +242,9 @@ impl LogQueue { return; } if state.evict_for(priority).is_some() { + if status == FormatStatus::Truncated { + state.loss.truncated += 1; + } state.push(LogRecord { sequence, priority, @@ -214,6 +261,18 @@ impl LogQueue { } } + /// 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) { + let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + if state.closed { + return; + } + state.loss.rejected += 1; + 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 when the queue empties @@ -222,7 +281,7 @@ impl LogQueue { pub(crate) fn take_batch(&self) -> Batch { let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); loop { - if state.len == 0 && !state.closed { + if state.len == 0 && state.loss.is_empty() && !state.closed { state = self .work_available .wait(state) diff --git a/crates/gateway-logging/src/redact.rs b/crates/gateway-logging/src/redact.rs index a3e15e88..3646096d 100644 --- a/crates/gateway-logging/src/redact.rs +++ b/crates/gateway-logging/src/redact.rs @@ -12,20 +12,111 @@ //! name is involved; redaction never reorders or truncates the rest of //! the line. +/// The mask replacing a sensitive value. +const REDACTED: &str = "[redacted]"; + +/// 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 from_str(text: &str, capacity: usize) -> Self { + let mut bounded = Self::new(capacity); + bounded.push_str(text); + bounded + } + + fn capacity(&self) -> usize { + self.storage.len() + } + + fn as_str(&self) -> Result<&str, std::str::Utf8Error> { + std::str::from_utf8(&self.storage[..self.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) -> Option { + let text = RedactedLine::from_str(text, capacity); + let text = redact_header_values(text, "authorization:")?; + let text = redact_header_values(text, "cookie:")?; + let text = redact_header_values(text, "set-cookie:")?; + let text = redact_bearer_tokens(text)?; + redact_api_key_assignments(text) +} + /// Masks the sensitive shapes `text` could carry and returns the result. -/// The input passes through unchanged when nothing matches, which is the -/// common case. +/// 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 text = redact_header_values(text, "authorization:"); - let text = redact_header_values(&text, "cookie:"); - let text = redact_header_values(&text, "set-cookie:"); - let text = redact_bearer_tokens(&text); - redact_api_key_assignments(&text) + let capacity = text.len().saturating_mul(REDACTED.len()); + redact_line_bounded(text, capacity) + .and_then(|redacted| redacted.finish("", false)) + .filter(|(_, truncated)| !truncated) + .map_or_else(String::new, |(text, _)| text.into()) } -/// The mask replacing a sensitive value. -const REDACTED: &str = "[redacted]"; - /// 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. @@ -42,9 +133,14 @@ fn find_ascii(haystack: &str, needle: &str, from: usize) -> Option { /// Redacts everything after a header name up to the end of the line: an /// `Authorization:` or `Cookie:` value runs to the line's end in the /// one-line-per-event format the fmt layer produces. -fn redact_header_values(text: &str, header: &str) -> String { - let mut out = String::with_capacity(text.len()); - let mut rest = text; +fn redact_header_values(input: RedactedLine, header: &str) -> Option { + if find_ascii(input.as_str().ok()?, header, 0).is_none() { + return Some(input); + } + let capacity = input.capacity(); + let inherited_truncation = input.truncated; + let mut out = RedactedLine::new(capacity); + let mut rest = input.as_str().ok()?; while let Some(start) = find_ascii(rest, header, 0) { let mut value_start = start + header.len(); // The conventional space after the colon is kept with the name. @@ -59,17 +155,23 @@ fn redact_header_values(text: &str, header: &str) -> String { rest = &rest[value_end..]; } out.push_str(rest); - out + out.truncated |= inherited_truncation; + Some(out) } /// Redacts the token after `Bearer `, the shape an authorization value /// takes when it appears without its header name (an interpolated error, /// a URL query). The token is the run of non-whitespace following the /// scheme. -fn redact_bearer_tokens(text: &str) -> String { +fn redact_bearer_tokens(input: RedactedLine) -> Option { const SCHEME: &str = "bearer "; - let mut out = String::with_capacity(text.len()); - let mut rest = text; + if find_ascii(input.as_str().ok()?, SCHEME, 0).is_none() { + return Some(input); + } + let capacity = input.capacity(); + let inherited_truncation = input.truncated; + let mut out = RedactedLine::new(capacity); + let mut rest = input.as_str().ok()?; let mut from = 0; while let Some(start) = find_ascii(rest, SCHEME, from) { let token_start = start + SCHEME.len(); @@ -86,17 +188,23 @@ fn redact_bearer_tokens(text: &str) -> String { from = 0; } out.push_str(rest); - out + out.truncated |= inherited_truncation; + Some(out) } /// Redacts the value of an `api_key` assignment in the shapes configs and /// JSON take: `api_key = "v"`, `api_key="v"`, `"api_key": "v"`, and bare /// `api_key = v`. The key name is kept so the log still says which field /// was masked. -fn redact_api_key_assignments(text: &str) -> String { +fn redact_api_key_assignments(input: RedactedLine) -> Option { const KEY: &str = "api_key"; - let mut out = String::with_capacity(text.len()); - let mut rest = text; + if find_ascii(input.as_str().ok()?, KEY, 0).is_none() { + return Some(input); + } + let capacity = input.capacity(); + let inherited_truncation = input.truncated; + let mut out = RedactedLine::new(capacity); + let mut rest = input.as_str().ok()?; let mut from = 0; while let Some(start) = find_ascii(rest, KEY, from) { let after_key = start + KEY.len(); @@ -145,7 +253,8 @@ fn redact_api_key_assignments(text: &str) -> String { from = 0; } out.push_str(rest); - out + out.truncated |= inherited_truncation; + Some(out) } #[cfg(test)] diff --git a/crates/gateway-logging/src/writer.rs b/crates/gateway-logging/src/writer.rs index d79b012e..31a3be98 100644 --- a/crates/gateway-logging/src/writer.rs +++ b/crates/gateway-logging/src/writer.rs @@ -6,8 +6,13 @@ use std::sync::Arc; use tracing::Metadata; use tracing_subscriber::fmt::MakeWriter; -use crate::queue::{LogPriority, LogQueue}; -use crate::redact::redact_line; +use crate::config::LOG_LIMITS; +use crate::queue::{FormatStatus, LogPriority, LogQueue}; +use crate::redact::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. @@ -65,7 +70,9 @@ impl<'a> MakeWriter<'a> for LogWriter { pub struct LogEventWriter { queue: Arc, priority: LogPriority, - buffer: Vec, + buffer: BoundedBytes, + truncated: bool, + utf8: Utf8Validator, } impl LogEventWriter { @@ -73,14 +80,18 @@ impl LogEventWriter { Self { queue, priority, - buffer: Vec::new(), + 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.buffer.extend_from_slice(buffer); + self.utf8.push(buffer); + let retained = self.buffer.extend_from_slice(buffer); + self.truncated |= retained < buffer.len(); Ok(buffer.len()) } @@ -94,24 +105,358 @@ impl Drop for LogEventWriter { if self.buffer.is_empty() { return; } - // The formatter's output is almost always valid UTF-8, so move the - // buffer into the record and pay the lossy copy only when it is not. - let line = match String::from_utf8(std::mem::take(&mut self.buffer)) { - Ok(text) => text, - Err(error) => String::from_utf8_lossy(error.as_bytes()).into_owned(), + 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. - self.queue - .enqueue(self.priority, redact_line(&line).into_boxed_str()); + let Some((line, truncated)) = + redact_line_bounded(line, LOG_LIMITS.max_formatted_record_bytes) + .and_then(|redacted| redacted.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::io::Write as _; + #[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() { + let queue = Arc::new(LogQueue::new()); + let writer = LogWriter::new(Arc::clone(&queue)); + let expansion_heavy = + "api_key=x ".repeat(LOG_LIMITS.max_formatted_record_bytes / "api_key=x ".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" + ); + assert!( + !batch.records[0].line.contains("api_key=x"), + "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()); diff --git a/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index 268537a3..2a79aad5 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -284,7 +284,7 @@ isProject: false - 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 +### 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. From 8f81dea18960b92d55a549a1c42309eb952d8ed3 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 10:12:46 -0700 Subject: [PATCH 58/86] Order and bound logging queue admission Bind sequence assignment to successful admission and enforce record and byte ceilings under the same queue lock. Preserve priority eviction while fencing pressure summaries after all records admitted before the low-water transition, so repeated pressure episodes remain distinct and observable. - `State` owns queued bytes, the next sequence, loss counts, and pending summaries under one mutex, while `QueueLimits` defines record and byte ceilings and their shared half-capacity low-water mark. - `enqueue_after` rejects records larger than the byte budget, evicts eligible lower-priority records until both ceilings permit admission, and blocks producers when neither admission nor priority-safe eviction can proceed. - `PendingSummary` fixes each closed loss episode after its admitted tail and before later records; a second pressure episode receives independent dropped, truncated, and rejected counts. - `byte_blocked_producers_wake_after_drain_and_close` proves byte-blocked producers wake after capacity returns or admission closes. - `crates/gateway-logging/src/queue.rs` retains unbounded producer waits; shutdown timeout handling remains outside this change. Design: new oversized-unit @ crates/gateway-logging/src/queue.rs::byte_blocked_producers_wake_after_drain_and_close Violates: A2 - credential ownership in gateway logging is not determinable from diff Deferred: Producer wait and shutdown timeout handling remain outside this commit. Plan: vibe/2026-09-07-1-promptforge-debt.md --- crates/gateway-logging/src/queue.rs | 473 +++++++++++++++++++++++--- vibe/2026-09-07-1-promptforge-debt.md | 2 +- vibe/archdoc-next.md | 2 + 3 files changed, 426 insertions(+), 51 deletions(-) diff --git a/crates/gateway-logging/src/queue.rs b/crates/gateway-logging/src/queue.rs index 8c0080f9..fc2e326d 100644 --- a/crates/gateway-logging/src/queue.rs +++ b/crates/gateway-logging/src/queue.rs @@ -2,9 +2,10 @@ //! total capacity, and eviction rules that protect Warn and Error records. use std::collections::VecDeque; -use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Condvar, Mutex, PoisonError}; +use crate::config::LOG_LIMITS; + /// Total records the queue holds before producers evict or block. pub(crate) const CAPACITY: usize = 8192; @@ -93,15 +94,42 @@ pub(crate) struct LogQueue { state: Mutex, work_available: Condvar, space_available: Condvar, - next_sequence: AtomicU64, + limits: QueueLimits, +} + +/// 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, +} + +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, + #[cfg(test)] + peak_queued_bytes: usize, +} + +/// 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, } /// Counts every way record content is lost during one observable pressure @@ -139,9 +167,29 @@ impl LossCounts { } impl State { - fn push(&mut self, record: LogRecord) { - self.lanes[record.priority.lane()].push_back(record); + 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, status: FormatStatus) { + if status == FormatStatus::Truncated { + self.loss.truncated += 1; + } + 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 @@ -151,15 +199,14 @@ impl State { if let Some(record) = self.lanes[lane_priority.lane()].pop_front() { self.loss.evicted[record.priority.lane()] += 1; self.len -= 1; + self.queued_bytes -= record.line.len(); return Some(record); } } None } - /// Pops the lane head with the smallest global sequence, so drained - /// output stays chronological across lanes. - fn pop_oldest(&mut self) -> Option { + fn oldest_lane(&self) -> Option { let mut oldest: Option = None; for (index, lane) in self.lanes.iter().enumerate() { let Some(front) = lane.front() else { @@ -174,41 +221,95 @@ impl State { _ => oldest = Some(index), } } - let index = oldest?; + 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(); - self.len -= 1; + if let Some(record) = &record { + self.len -= 1; + self.queued_bytes -= record.line.len(); + } record } - /// Builds the one synthetic summary of a pressure episode and resets - /// the counters; `None` when no content was lost since the last - /// summary. - fn take_summary(&mut self) -> Option> { - self.loss.take_summary() + fn close_loss_episode(&mut self) { + let Some(text) = self.loss.take_summary() else { + return; + }; + self.pending_summaries.push_back(PendingSummary { + after_sequence: self.next_sequence, + text, + }); + } + + 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; + } + self.pending_summaries + .pop_front() + .map(|summary| summary.text) } } impl LogQueue { pub(crate) fn new() -> Self { + Self::with_limits(CAPACITY, LOG_LIMITS.max_queued_bytes) + } + + fn with_limits(max_records: usize, max_bytes: usize) -> 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), + ), + #[cfg(test)] + peak_queued_bytes: 0, }), work_available: Condvar::new(), space_available: Condvar::new(), - next_sequence: AtomicU64::new(0), + limits: QueueLimits { + max_records, + max_bytes, + }, } } - /// Enqueues `line`, assigning its global sequence before the lock is - /// taken so formatting and allocation never happen under the mutex. On - /// a full queue the oldest eligible lower-priority record is evicted; - /// with none eligible the producer blocks on the condition variable - /// until the worker frees space. After [`close`](Self::close) new - /// records are dropped. + /// 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 [`close`](Self::close) + /// new records are dropped. #[cfg(test)] pub(crate) fn enqueue(&self, priority: LogPriority, line: Box) { self.enqueue_formatted(priority, line, FormatStatus::Complete); @@ -222,38 +323,40 @@ impl LogQueue { line: Box, status: FormatStatus, ) { - let sequence = self.next_sequence.fetch_add(1, Ordering::Relaxed); + 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(), + ) { + let line_bytes = line.len(); + before_admission(); let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); loop { if state.closed { return; } - if state.len < CAPACITY { - if status == FormatStatus::Truncated { - state.loss.truncated += 1; - } - state.push(LogRecord { - sequence, - priority, - line, - }); + if line_bytes > self.limits.max_bytes { + state.loss.rejected += 1; drop(state); self.work_available.notify_one(); return; } - if state.evict_for(priority).is_some() { - if status == FormatStatus::Truncated { - state.loss.truncated += 1; - } - state.push(LogRecord { - sequence, - priority, - line, - }); + if state.can_admit(self.limits, line_bytes) { + state.admit(priority, line, status); drop(state); self.work_available.notify_one(); return; } + if state.evict_for(priority).is_some() { + continue; + } state = self .space_available .wait(state) @@ -275,13 +378,17 @@ impl LogQueue { /// 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 when the queue empties - /// after evictions. Every write happens on the caller's side, outside - /// the mutex. + /// 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 { - if state.len == 0 && state.loss.is_empty() && !state.closed { + if state.len == 0 + && state.loss.is_empty() + && state.pending_summaries.is_empty() + && !state.closed + { state = self .work_available .wait(state) @@ -289,18 +396,28 @@ impl LogQueue { 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); } - let summary = if state.len == 0 { - state.take_summary() - } else { - None - }; - let done = state.closed && state.len == 0; + if self.limits.is_at_low_water(state.len, state.queued_bytes) { + state.close_loss_episode(); + } + let summary = state.take_ready_summary(); + let done = state.closed + && state.len == 0 + && state.loss.is_empty() + && state.pending_summaries.is_empty(); drop(state); self.space_available.notify_all(); return Batch { @@ -322,6 +439,17 @@ impl LogQueue { == 0 } + #[cfg(test)] + fn new_for_test(max_records: usize, max_bytes: usize) -> Self { + Self::with_limits(max_records, max_bytes) + } + + #[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) + } + /// Closes admission and wakes every waiter: producers drop new /// records, blocked producers return, and the worker exits once the /// queue drains. @@ -512,6 +640,251 @@ mod tests { 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 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 byte_blocked_producers_wake_after_drain_and_close() { + let queue = Arc::new(LogQueue::new_for_test(4, 4)); + 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"); + + 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::Debug, + 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 = drain_all(&queue); + assert_eq!( + remaining + .iter() + .map(|record| record.line.as_ref()) + .collect::>(), + ["wwww"], + "close wakes the producer without admitting its record" + ); + } + #[test] fn pressure_emits_one_summary_after_the_queue_empties() { let queue = LogQueue::new(); diff --git a/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index 2a79aad5..bcee10a9 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -294,7 +294,7 @@ isProject: false - 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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index c67827e1..0217b926 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -186,3 +186,5 @@ N57 | observation | surface-growth @ crates/workshop-server/ui/src/ui/stt.ts::St 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 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 +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 From e4370e8d73f24fa650acae9a79abb6e6a1eca024 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 10:31:15 -0700 Subject: [PATCH 59/86] Bound logging stalls and shutdown Apply configured deadlines to protected producers and logger shutdown, including time spent acquiring the queue mutex. Preserve loss counts across close races, attempt an emergency diagnostic after timeout, and detach stalled workers while healthy sinks still drain, flush, and join. - `LogQueue` registers active producers through `admission_gate` before mutex acquisition and keeps outstanding record, summary, and pressure counts in preallocated atomics. - `LogWorker` replaces the unit owner with a single-field handle owner; `is_finished` supports bounded waiting and `join` consumes only a finished worker. - `enqueue_after` starts the producer deadline before its admission hook, includes mutex and condition-variable waits, and records a timeout as rejected pressure. - `close_locked` preaccounts `blocked_producers`; `close_accounted` prevents an awakened producer from reporting the same close loss twice. - `shutdown_with_waiter` reserves up to `MAX_EMERGENCY_START_WAIT` for diagnostic startup, shares the remaining budget across queue closure and worker completion, and invokes `abandon` before it drops an unfinished owner. - `shutdown` converts both `Joined` and `Detached` outcomes into a successful unit result; detachment loss reaches best-effort stderr through `write_emergency_diagnostic`. - `complete_batch` runs only after `flush`; deterministic `StallPoint` tests block `Write`, `Flush`, queue closure, and the diagnostic helper while the healthy path still joins. - `attempt_emergency_diagnostic` starts a detached stderr helper and waits only for its startup handshake; it adds no spool and does not await the diagnostic write. Design: new shared-parameter-cluster @ crates/gateway-logging/src/queue.rs::LogQueue::new_for_test_with_wait Design: new oversized-unit @ crates/gateway-logging/src/queue.rs::LogQueue::enqueue_after Design: new flag-parameter @ crates/gateway-logging/src/queue.rs::LogQueue::complete_batch Design: removes oversized-unit @ crates/gateway-logging/src/queue.rs::byte_blocked_producers_wake_after_drain_and_close Design: new surface-growth @ crates/gateway-logging/src/runtime.rs::LogRuntime::shutdown boundary: pub Design: new swallowed-exception @ crates/gateway-logging/src/runtime.rs::LogRuntime::shutdown boundary: pub Design: new oversized-unit @ crates/gateway-logging/src/runtime.rs::assert_stalled_shutdown Design: new newtype @ crates/gateway-logging/src/worker.rs::LogWorker Violates: A2 - credential ownership in gateway logging is not determinable from diff Plan: vibe/2026-09-07-1-promptforge-debt.md --- crates/gateway-logging/src/queue.rs | 560 ++++++++++++++++++++++++-- crates/gateway-logging/src/runtime.rs | 291 ++++++++++++- crates/gateway-logging/src/worker.rs | 134 +++++- crates/gateway/src/main.rs | 5 +- vibe/2026-09-07-1-promptforge-debt.md | 2 +- vibe/archdoc-next.md | 9 +- 6 files changed, 939 insertions(+), 62 deletions(-) diff --git a/crates/gateway-logging/src/queue.rs b/crates/gateway-logging/src/queue.rs index fc2e326d..3ccc8a61 100644 --- a/crates/gateway-logging/src/queue.rs +++ b/crates/gateway-logging/src/queue.rs @@ -2,10 +2,15 @@ //! total capacity, and eviction rules that protect Warn and Error records. use std::collections::VecDeque; -use std::sync::{Condvar, Mutex, PoisonError}; +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_PRODUCERS: u64 = ADMISSION_CLOSED - 1; + /// Total records the queue holds before producers evict or block. pub(crate) const CAPACITY: usize = 8192; @@ -78,6 +83,7 @@ pub(crate) struct LogRecord { pub(crate) struct Batch { pub(crate) records: Vec, pub(crate) summary: Option>, + pub(crate) summary_affected: u64, pub(crate) done: bool, } @@ -95,6 +101,15 @@ pub(crate) struct LogQueue { work_available: Condvar, space_available: Condvar, limits: QueueLimits, + admission_gate: AtomicU64, + abandoned: AtomicBool, + pending_rejections: AtomicU64, + outstanding_records: 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 @@ -103,6 +118,7 @@ pub(crate) struct LogQueue { struct QueueLimits { max_records: usize, max_bytes: usize, + producer_wait: Duration, } impl QueueLimits { @@ -120,16 +136,32 @@ struct State { 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 @@ -146,12 +178,24 @@ impl LossCounts { self.evicted.iter().all(|&count| count == 0) && self.truncated == 0 && self.rejected == 0 } - fn take_summary(&mut self) -> Option> { + 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: u64 = self.evicted.iter().sum::() + self.rejected; - let affected = dropped + self.truncated; + 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()], @@ -162,7 +206,7 @@ impl LossCounts { ) .into_boxed_str(); *self = Self::default(); - Some(summary) + Some((summary, affected)) } } @@ -172,10 +216,7 @@ impl State { && line_bytes <= limits.max_bytes.saturating_sub(self.queued_bytes) } - fn admit(&mut self, priority: LogPriority, line: Box, status: FormatStatus) { - if status == FormatStatus::Truncated { - self.loss.truncated += 1; - } + 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); @@ -193,11 +234,10 @@ impl State { } /// Evicts the oldest record the incoming priority is allowed to - /// displace, counting the eviction by the evicted record's level. + /// 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.loss.evicted[record.priority.lane()] += 1; self.len -= 1; self.queued_bytes -= record.line.len(); return Some(record); @@ -243,12 +283,14 @@ impl State { } fn close_loss_episode(&mut self) { - let Some(text) = self.loss.take_summary() else { + 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, }); } @@ -258,7 +300,7 @@ impl State { .map(|summary| summary.after_sequence) } - fn take_ready_summary(&mut self) -> Option> { + fn take_ready_summary(&mut self) -> Option { let fence = self.pending_summary_fence()?; if self .oldest_sequence() @@ -266,18 +308,24 @@ impl State { { return None; } - self.pending_summaries - .pop_front() - .map(|summary| summary.text) + 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) + Self::with_limits( + CAPACITY, + LOG_LIMITS.max_queued_bytes, + LOG_LIMITS.producer_wait, + ) } - fn with_limits(max_records: usize, max_bytes: usize) -> Self { + 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 { @@ -291,6 +339,11 @@ impl LogQueue { 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, }), @@ -299,8 +352,130 @@ impl LogQueue { limits: QueueLimits { max_records, max_bytes, + producer_wait, }, + admission_gate: AtomicU64::new(0), + abandoned: AtomicBool::new(false), + pending_rejections: AtomicU64::new(0), + outstanding_records: 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 { + return false; + } + match self.admission_gate.compare_exchange_weak( + gate, + gate + 1, + Ordering::AcqRel, + Ordering::Acquire, + ) { + Ok(_) => return true, + Err(current) => gate = current, + } + } + } + + fn finish_producer(&self) { + let previous = self.admission_gate.fetch_sub(1, Ordering::AcqRel); + debug_assert!(previous & ACTIVE_PRODUCERS > 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 + } + + 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::saturating_sub(&self.outstanding_records, 1); + self.unreported_pressure_records + .fetch_add(1, Ordering::AcqRel); } /// Enqueues `line`, assigning its global sequence atomically with @@ -308,8 +483,8 @@ impl LogQueue { /// 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 [`close`](Self::close) - /// new records are dropped. + /// 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); @@ -335,43 +510,111 @@ impl LogQueue { status: FormatStatus, before_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 mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + let Some(mut state) = self.lock_until(deadline) else { + if !self.is_abandoned() { + self.record_rejection_without_lock(); + } + self.finish_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.record_rejection(&mut state); + } + self.finish_producer(); + drop(state); + self.work_available.notify_one(); return; } if line_bytes > self.limits.max_bytes { - state.loss.rejected += 1; + self.record_rejection(&mut state); + self.finish_producer(); drop(state); self.work_available.notify_one(); return; } if state.can_admit(self.limits, line_bytes) { - state.admit(priority, line, status); + if status == FormatStatus::Truncated { + self.record_truncation(&mut state); + } + state.admit(priority, line); + self.outstanding_records.fetch_add(1, Ordering::AcqRel); + self.finish_producer(); drop(state); self.work_available.notify_one(); return; } - if state.evict_for(priority).is_some() { + if let Some(evicted) = state.evict_for(priority) { + self.record_eviction(&mut state, &evicted); continue; } - state = self + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + self.record_rejection(&mut state); + self.finish_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(state) + .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_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) { - let mut state = self.state.lock().unwrap_or_else(PoisonError::into_inner); + 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_producer(); + self.work_available.notify_one(); + return; + }; + self.merge_pending_rejections(&mut state); if state.closed { + self.record_rejection(&mut state); + self.finish_producer(); + drop(state); + self.work_available.notify_one(); return; } - state.loss.rejected += 1; + self.record_rejection(&mut state); + self.finish_producer(); drop(state); self.work_available.notify_one(); } @@ -384,10 +627,19 @@ impl LogQueue { 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 + && (!state.closed || self.active_producers() > 0) { state = self .work_available @@ -413,16 +665,25 @@ impl LogQueue { if self.limits.is_at_low_water(state.len, state.queued_bytes) { state.close_loss_episode(); } - let summary = state.take_ready_summary(); + 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(); + && state.pending_summaries.is_empty() + && self.active_producers() == 0; drop(state); self.space_available.notify_all(); return Batch { records, summary, + summary_affected, done, }; } @@ -440,8 +701,28 @@ impl LogQueue { } #[cfg(test)] - fn new_for_test(max_records: usize, max_bytes: usize) -> Self { - Self::with_limits(max_records, max_bytes) + 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)] @@ -450,16 +731,104 @@ impl LogQueue { (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::saturating_sub( + &self.outstanding_records, + 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 + .swap(0, Ordering::AcqRel) + .saturating_add(self.active_producers()), + 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); - state.closed = true; + 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); + } } #[cfg(test)] @@ -558,7 +927,11 @@ mod tests { #[test] fn a_producer_with_no_eligible_record_blocks_until_space_opens() { - let queue = Arc::new(LogQueue::new()); + 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}"))); } @@ -600,6 +973,88 @@ mod tests { ); } + #[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(); @@ -786,8 +1241,12 @@ mod tests { } #[test] - fn byte_blocked_producers_wake_after_drain_and_close() { - let queue = Arc::new(LogQueue::new_for_test(4, 4)); + 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); @@ -836,7 +1295,15 @@ mod tests { 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(); @@ -844,7 +1311,7 @@ mod tests { let (close_done_tx, close_done_rx) = mpsc::channel(); let close_waiter = std::thread::spawn(move || { close_queue.enqueue_after( - LogPriority::Debug, + LogPriority::Error, line("z"), FormatStatus::Complete, || { @@ -874,15 +1341,24 @@ mod tests { .recv_timeout(Duration::from_secs(5)) .expect("close wakes the byte-blocked producer"); close_waiter.join().expect("the close waiter joins"); - let remaining = drain_all(&queue); + 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] @@ -946,7 +1422,11 @@ mod tests { 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()); + 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 || { diff --git a/crates/gateway-logging/src/runtime.rs b/crates/gateway-logging/src/runtime.rs index e685b28a..93df7019 100644 --- a/crates/gateway-logging/src/runtime.rs +++ b/crates/gateway-logging/src/runtime.rs @@ -3,20 +3,23 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::thread::JoinHandle; +use std::time::{Duration, Instant}; -use crate::config::LogConfig; +use crate::config::{LOG_LIMITS, LogConfig}; use crate::error::LogError; -use crate::queue::LogQueue; +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 the final records still reach the disk. +/// which the caller runs last so a healthy sink receives final records +/// without allowing a stalled sink to hold process exit forever. /// /// # Examples /// ``` @@ -30,10 +33,16 @@ use crate::writer::LogWriter; #[derive(Debug)] pub struct LogRuntime { queue: Arc, - worker: Option>, + 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. @@ -96,8 +105,11 @@ impl LogRuntime { &self.path } - /// Closes admission, drains every queued record, flushes the sink, and - /// joins the worker thread. Records enqueued after this call are + /// 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 @@ -112,19 +124,104 @@ impl LogRuntime { /// # std::fs::remove_dir_all(&dir).ok(); /// # Ok::<(), gateway_logging::LogError>(()) /// ``` - pub fn shutdown(mut self) -> Result<(), LogError> { - self.queue.close(); + 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(()) + 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); @@ -207,4 +304,178 @@ mod tests { ); } } + + 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 index c60d5847..e8a2869e 100644 --- a/crates/gateway-logging/src/worker.rs +++ b/crates/gateway-logging/src/worker.rs @@ -51,6 +51,20 @@ enum Sink { /// 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 { @@ -70,6 +84,22 @@ impl Sink { } #[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, + .. + } => {} } } @@ -88,6 +118,22 @@ impl Sink { } #[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, + .. + } => {} } } @@ -100,9 +146,12 @@ impl Sink { } /// The worker owner: spawned by -/// [`LogRuntime::start`](crate::LogRuntime::start), joined by -/// [`LogRuntime::shutdown`](crate::LogRuntime::shutdown). -pub(crate) struct LogWorker; +/// [`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 @@ -111,25 +160,96 @@ impl LogWorker { /// /// # Errors /// Returns the I/O failure from spawning the thread. - pub(crate) fn spawn(queue: Arc, file: File) -> io::Result> { - std::thread::Builder::new() + pub(crate) fn spawn(queue: Arc, file: File) -> io::Result { + Self::spawn_with_sink(queue, Sink::File(BufWriter::new(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 || { - let mut sink = Sink::File(BufWriter::new(file)); 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(()); } } diff --git a/crates/gateway/src/main.rs b/crates/gateway/src/main.rs index c385e29c..d2545b12 100644 --- a/crates/gateway/src/main.rs +++ b/crates/gateway/src/main.rs @@ -142,8 +142,9 @@ fn main() -> ExitCode { ExitCode::FAILURE } }; - // The logger shuts down last, so the terminal outcome and every record - // behind it drain to the disk before the process exits. + // 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() { diff --git a/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index bcee10a9..ab98be38 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -304,7 +304,7 @@ isProject: false - 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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 0217b926..89f6f783 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -186,5 +186,10 @@ N57 | observation | surface-growth @ crates/workshop-server/ui/src/ui/stt.ts::St 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 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 -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 +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 +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 From fc2f9292e2e9d560e060bbead3f846844ea27bfc Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 10:52:39 -0700 Subject: [PATCH 60/86] Pin native CI to the exact Rust toolchain Remove floating Rust selection and the fixed user-profile tool layout from native CI. Pin RUSTUP_TOOLCHAIN to 1.89.0, set RUSTUP_AUTO_INSTALL to zero, and validate cargo.exe and rustc.exe from PATH or PROMPTFORGE_RUST_1_89_0_BIN before Cargo caching. - The versioned contract accepts only an absolute existing directory. It adds that directory to GITHUB_PATH only after both tools pass. - The preflight rejects missing tools, command failures, unknown version output, and versions other than 1.89.0. - tools/check-stt-native-workflow.test.mjs adds checks for the pinned MSRV contract and keeps Miri setup and fixture hashes unchanged. --- .github/workflows/stt-miri.yml | 94 +++++++++++---- tools/check-stt-native-workflow.test.mjs | 140 +++++++++++++++++------ 2 files changed, 179 insertions(+), 55 deletions(-) diff --git a/.github/workflows/stt-miri.yml b/.github/workflows/stt-miri.yml index 0b56b38c..309c7977 100644 --- a/.github/workflows/stt-miri.yml +++ b/.github/workflows/stt-miri.yml @@ -47,35 +47,87 @@ jobs: runs-on: [self-hosted, windows, cuda] timeout-minutes: 90 env: - RUSTUP_TOOLCHAIN: stable + RUSTUP_TOOLCHAIN: 1.89.0 + RUSTUP_AUTO_INSTALL: "0" steps: - uses: actions/checkout@v4 - - name: Verify preinstalled stable Rust + - name: Verify preinstalled MSRV Rust shell: powershell run: | - $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)) { - throw 'self-hosted runner Rust is not provisioned: missing rustup.exe' + $ErrorActionPreference = 'Stop' + $requiredVersion = '1.89.0' + $contractName = 'PROMPTFORGE_RUST_1_89_0_BIN' + $contractBin = [Environment]::GetEnvironmentVariable($contractName) + + 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'" + } + $contractBin = (Resolve-Path $contractBin).Path } - if (-not (Test-Path $cargo -PathType Leaf)) { - throw 'self-hosted runner Rust is not provisioned: missing cargo.exe' - } - $env:RUSTUP_AUTO_INSTALL = '0' - $cargoBin | Add-Content $env:GITHUB_PATH - $toolchains = @(& $rustup toolchain list) - if ($LASTEXITCODE -ne 0) { - throw 'self-hosted runner Rust is not provisioned: rustup toolchain list failed' + + function Resolve-RustTool { + param( + [Parameter(Mandatory = $true)][string] $Name, + [string] $Bin + ) + + if (-not [string]::IsNullOrWhiteSpace($Bin)) { + $candidate = Join-Path $Bin "$Name.exe" + if (-not (Test-Path $candidate -PathType Leaf)) { + throw "self-hosted runner Rust $requiredVersion contract $contractName is missing $Name.exe at '$candidate'" + } + return (Resolve-Path $candidate).Path + } + + $command = Get-Command "$Name.exe" -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + if (-not $command) { + throw "self-hosted runner Rust $requiredVersion is not provisioned: $Name.exe was not found on PATH; install it outside CI or set $contractName to its versioned bin directory" + } + return $command.Source } - $stableToolchain = $toolchains | Where-Object { $_ -match '^stable(?:-|\s|$)' } | Select-Object -First 1 - if (-not $stableToolchain) { - throw 'self-hosted runner Rust is not provisioned: stable toolchain is missing' + + 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" } - & $cargo '+stable' '--version' - if ($LASTEXITCODE -ne 0) { - throw 'self-hosted runner Rust is not provisioned: stable toolchain is unavailable' + + $cargo = Resolve-RustTool -Name 'cargo' -Bin $contractBin + $rustc = Resolve-RustTool -Name 'rustc' -Bin $contractBin + Assert-RustToolVersion -Name 'cargo' -ToolPath $cargo + Assert-RustToolVersion -Name 'rustc' -ToolPath $rustc + + if (-not [string]::IsNullOrWhiteSpace($contractBin)) { + $contractBin | Add-Content -Path $env:GITHUB_PATH } - name: Cache Cargo diff --git a/tools/check-stt-native-workflow.test.mjs b/tools/check-stt-native-workflow.test.mjs index b159bd25..14d72c6f 100644 --- a/tools/check-stt-native-workflow.test.mjs +++ b/tools/check-stt-native-workflow.test.mjs @@ -9,6 +9,8 @@ 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"); function jobSource(name) { const marker = ` ${name}:\n`; @@ -22,54 +24,117 @@ function jobSource(name) { return workflow.slice(start, end); } -test("native runner validates provisioned stable Rust before caching", () => { +test("native runner validates the exact repository MSRV before caching", () => { const native = jobSource("native-whisper"); - const preflight = native.indexOf("- name: Verify preinstalled stable Rust"); + const preflight = native.indexOf("- name: Verify preinstalled MSRV Rust"); const cache = native.indexOf("- name: Cache Cargo"); - const disableAutoInstall = native.indexOf( - "$env:RUSTUP_AUTO_INSTALL = '0'", + const resolveCargo = native.indexOf( + "$cargo = Resolve-RustTool -Name 'cargo' -Bin $contractBin", ); - const listToolchains = native.indexOf( - "$toolchains = @(& $rustup toolchain list)", + const resolveRustc = native.indexOf( + "$rustc = Resolve-RustTool -Name 'rustc' -Bin $contractBin", + ); + const validateCargo = native.indexOf( + "Assert-RustToolVersion -Name 'cargo' -ToolPath $cargo", + ); + const validateRustc = native.indexOf( + "Assert-RustToolVersion -Name 'rustc' -ToolPath $rustc", + ); + const publishContract = native.indexOf( + "$contractBin | Add-Content -Path $env:GITHUB_PATH", ); - const requireStable = native.indexOf("if (-not $stableToolchain)"); - const invokeCargo = native.indexOf("& $cargo '+stable' '--version'"); 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( - disableAutoInstall > preflight, - "native preflight must disable rustup auto-install", + publishContract > validateRustc, + "contract bin must not reach PATH before both versions pass", ); - assert.ok( - listToolchains > disableAutoInstall, - "native preflight must inspect installed toolchains after disabling auto-install", + 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\.0$/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 come from PATH or the versioned runner contract", () => { + const native = jobSource("native-whisper"); + + assert.match( + native, + /\$contractName = 'PROMPTFORGE_RUST_1_89_0_BIN'/, ); - assert.ok( - requireStable > listToolchains, - "native preflight must reject a missing stable toolchain", + assert.match(native, /\[IO\.Path\]::IsPathRooted\(\$contractBin\)/); + assert.match(native, /Test-Path \$contractBin -PathType Container/); + assert.match(native, /\$candidate = Join-Path \$Bin "\$Name\.exe"/); + assert.match( + native, + /Get-Command "\$Name\.exe" -CommandType Application -ErrorAction SilentlyContinue/, + ); + assert.match(native, /return \$command\.Source/); + assert.match(native, /\$contractBin \| Add-Content -Path \$env:GITHUB_PATH/); + assert.doesNotMatch(native, /\$env:USERPROFILE/); + assert.doesNotMatch(native, /\.cargo\\bin/); +}); + +test("rustup-managed PATH proxies use the exact preinstalled toolchain", () => { + const native = jobSource("native-whisper"); + + assert.match(native, /^\s+RUSTUP_TOOLCHAIN: 1\.89\.0$/m); + assert.match(native, /^\s+RUSTUP_AUTO_INSTALL: "0"$/m); + assert.match(native, /\$versionLines = @\(& \$ToolPath '--version' 2>&1\)/); + assert.doesNotMatch(native, /missing rustup\.exe/); + 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, + /contract \$contractName is missing \$Name\.exe at '\$candidate'/, ); - assert.ok( - invokeCargo > requireStable, - "missing stable must fail before Cargo runs", - ); - assert.match(native, /Join-Path \$env:USERPROFILE '\.cargo\\bin'/); - assert.match(native, /Join-Path \$cargoBin 'rustup\.exe'/); - assert.match(native, /Join-Path \$cargoBin 'cargo\.exe'/); - assert.match(native, /\$cargoBin \| Add-Content \$env:GITHUB_PATH/); - assert.match(native, /\$toolchains = @\(& \$rustup toolchain list\)/); assert.match( native, - /\$stableToolchain = \$toolchains \| Where-Object \{ \$_ -match '\^stable\(\?:-\|\\s\|\$\)' \} \| Select-Object -First 1/, + /\$Name\.exe was not found on PATH; install it outside CI or set \$contractName to its versioned bin directory/, ); - assert.match(native, /& \$cargo '\+stable' '--version'/); + assert.match(native, /\$Name\.exe failed at '\$ToolPath' with exit code/); + assert.match(native, /provision Rust \$requiredVersion 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); + assert.doesNotMatch( + native, + /rustup(?:-init)?(?:\.exe)?\s+(?:install|default|self update)/i, + ); }); test("hosted Miri job keeps its pinned nightly setup", () => { @@ -81,12 +146,19 @@ test("hosted Miri job keeps its pinned nightly setup", () => { assert.match(miri, /cargo \+nightly-2026-09-05 miri setup/); }); -test("native preflight reports clear provisioning failures", () => { +test("native fixture artifact hashes remain pinned", () => { const native = jobSource("native-whisper"); - assert.match(native, /self-hosted runner Rust is not provisioned: missing rustup\.exe/); - assert.match(native, /self-hosted runner Rust is not provisioned: missing cargo\.exe/); - assert.match(native, /self-hosted runner Rust is not provisioned: rustup toolchain list failed/); - assert.match(native, /self-hosted runner Rust is not provisioned: stable toolchain is missing/); - assert.match(native, /self-hosted runner Rust is not provisioned: stable toolchain is unavailable/); + assert.match( + native, + /F1BC54D7288E21EE826CCB5767249836B780FC316BEC4A0374873E73163DAE12/, + ); + assert.match( + native, + /921E4CF8686FDD993DCD081A5DA5B6C365BFDE1162E72B08D75AC75289920B1F/, + ); + assert.match( + native, + /59DFB9A4ACB36FE2A2AFFC14BACBEE2920FF435CB13CC314A08C13F66BA7860E/, + ); }); From b492a990840af5ecba54e40c0da90018df5ad634 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 10:54:15 -0700 Subject: [PATCH 61/86] Pin STT API toolchain and package selection Keep the STT architecture API gate deterministic across runners. Run standard cargo checks with 1.89.0, run cargo-public-api through nightly-2026-09-05, and select each crate by manifest and package name. - Provision 1.89.0 and nightly-2026-09-05 in .github/workflows/ci.yml. - Separate runCargo and runRustdocCargo so only public API inspection uses the pinned nightly. - Make runPublicApi stop on a missing nightly or virtual manifest error without a fallback. - Tests pin the command arguments, toolchain environment, exact package selection, and one-call failure behavior. - This diff does not change public API snapshots, module ceilings, or legacy STT configuration parsing. --- .github/workflows/ci.yml | 12 +++- tools/check-stt-architecture.mjs | 62 ++++++++++++++---- tools/check-stt-architecture.test.mjs | 90 +++++++++++++++++++++++++-- 3 files changed, 144 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9da9dc3f..55c9df17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,13 @@ jobs: with: components: rustfmt, clippy - - uses: dtolnay/rust-toolchain@1.89 + - 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 @@ -51,8 +57,8 @@ jobs: - name: Install architecture tools run: | - RUSTUP_TOOLCHAIN=1.89 cargo install cargo-modules --version 0.25.0 --locked - RUSTUP_TOOLCHAIN=1.89 cargo install cargo-public-api --version 0.52.0 --locked + 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 diff --git a/tools/check-stt-architecture.mjs b/tools/check-stt-architecture.mjs index 7dafdc3a..fa7b6e79 100644 --- a/tools/check-stt-architecture.mjs +++ b/tools/check-stt-architecture.mjs @@ -6,7 +6,8 @@ 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 RUSTUP_TOOLCHAIN = "1.89"; +const CARGO_TOOLCHAIN = "1.89.0"; +const RUSTDOC_TOOLCHAIN = "nightly-2026-09-05"; const STT_CRATES = [ "gateway-stt", "gateway-stt-engine", @@ -239,7 +240,7 @@ export function runCargo( const result = spawn("cargo", args, { cwd: root, encoding: "utf8", - env: { ...env, RUSTUP_TOOLCHAIN }, + env: { ...env, RUSTUP_TOOLCHAIN: CARGO_TOOLCHAIN }, maxBuffer: 64 * 1024 * 1024, windowsHide: true, }); @@ -254,6 +255,52 @@ export function runCargo( 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, + { spawn = spawnSync, env = process.env } = {}, +) { + const manifestPath = join(root, "crates", crateName, "Cargo.toml"); + return runRustdocCargo( + root, + [ + "public-api", + "--manifest-path", + manifestPath, + "--package", + crateName, + "-sss", + "--color", + "never", + ], + { spawn, env }, + ); +} + function checkNodeVersion() { const major = Number(process.versions.node.split(".")[0]); if (!Number.isInteger(major) || major < 22) { @@ -272,7 +319,7 @@ function main() { ); requireToolVersion( "cargo-public-api", - runCargo(root, ["public-api", "--version"]), + runRustdocCargo(root, ["public-api", "--version"]), CARGO_PUBLIC_API_VERSION, ); @@ -294,14 +341,7 @@ function main() { ]); assertAcyclic(parseCargoModulesDot(dot), crateName); - const publicApi = runCargo(root, [ - "public-api", - "-p", - crateName, - "-sss", - "--color", - "never", - ]); + const publicApi = runPublicApi(root, crateName); const rootNames = countEffectiveRootNames( publicApi, crateName.replaceAll("-", "_"), diff --git a/tools/check-stt-architecture.test.mjs b/tools/check-stt-architecture.test.mjs index ee5b0e18..8fa9b870 100644 --- a/tools/check-stt-architecture.test.mjs +++ b/tools/check-stt-architecture.test.mjs @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { join } from "node:path"; import test from "node:test"; import { @@ -10,6 +11,8 @@ import { requireExactPublicRootCount, requireToolVersion, runCargo, + runPublicApi, + runRustdocCargo, } from "./check-stt-architecture.mjs"; test("DOT parser collapses item edges to their owning modules", () => { @@ -133,12 +136,12 @@ test("cargo-modules child cannot inherit ambient Cargo 1.98", () => { 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"); + assert.equal(child.options.env.RUSTUP_TOOLCHAIN, "1.89.0"); }); test("cargo-public-api child cannot inherit ambient Cargo 1.98", () => { let child; - runCargo("repo", ["public-api", "--version"], { + 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 }; @@ -147,10 +150,85 @@ test("cargo-public-api child cannot inherit ambient Cargo 1.98", () => { }); assert.equal(child.command, "cargo"); - assert.deepEqual(child.args, ["public-api", "--version"]); + 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, "1.89"); + 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("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", () => { @@ -161,11 +239,11 @@ test("architecture cargo fails closed when Rust 1.89 is absent", () => { return { status: 1, stdout: "", - stderr: "toolchain '1.89' is not installed", + stderr: "toolchain '1.89.0' is not installed", }; }, }), - /failed with status 1.*toolchain '1\.89' is not installed/s, + /failed with status 1.*toolchain '1\.89\.0' is not installed/s, ); }); From f8e07fb6c81c043b58e80b3a25e89b8c09147bc5 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 11:04:59 -0700 Subject: [PATCH 62/86] Rotate logs within fixed byte budgets Bound each active and retained log segment and prune the oldest bytes before new writes exceed the aggregate disk budget. Preserve the current and numbered diagnostic names while reserving space for a truncation marker and one complete terminal record. Stage and sync replacements before transactional installation so rollback and restart recovery choose a complete old or committed chain. - `SegmentedFile` owns active and retained byte counts, enforces both budgets before admission, and rotates only after flushing and syncing the active file. - `rotate_files` creates durable staged copies, a preparation marker, rollback copies, and a durable commit marker. It restores old targets after an uncommitted failure and keeps committed targets during restart recovery. - `open_log_file_with_limits` recovers interrupted work, compacts oversized legacy segments at valid text boundaries, prunes oldest retained data, and shifts the existing active log into the same numbered layout. - `live_rotation_recovers_every_injected_filesystem_failure` and `restart_compaction_recovers_every_injected_filesystem_failure` inject each filesystem checkpoint, including remove-then-rename gaps, and prove recovery keeps one complete state. Design: new oversized-unit @ crates/gateway-logging/src/worker.rs Design: new pure-function @ crates/gateway-logging/src/worker.rs::valid_utf8_tail deps: &[u8] Design: new pure-function @ crates/gateway-logging/src/worker.rs::artifact_path deps: &Path,&str Design: new pure-function @ crates/gateway-logging/src/worker.rs::rotation_prepared_path deps: &Path Design: new pure-function @ crates/gateway-logging/src/worker.rs::rotation_committed_path deps: &Path Design: new pure-function @ crates/gateway-logging/src/worker.rs::rotation_targets deps: &Path,&[PathBuf] Design: new pure-function @ crates/gateway-logging/src/worker.rs::crashing_fault deps: usize Design: new oversized-unit @ crates/gateway-logging/src/worker.rs::live_rotation_recovers_every_injected_filesystem_failure Design: new oversized-unit @ crates/gateway-logging/src/worker.rs::byte_boundaries_rotate_a_full_numbered_chain_without_splitting_utf8 Violates: A2 - credential ownership in gateway logging is not determinable from diff Plan: vibe/2026-09-07-1-promptforge-debt.md --- crates/gateway-logging/src/config.rs | 33 +- crates/gateway-logging/src/worker.rs | 1035 ++++++++++++++++++++++++- vibe/2026-09-07-1-promptforge-debt.md | 2 +- vibe/archdoc-next.md | 1 + 4 files changed, 1027 insertions(+), 44 deletions(-) diff --git a/crates/gateway-logging/src/config.rs b/crates/gateway-logging/src/config.rs index 2fe01c13..9d5c9c31 100644 --- a/crates/gateway-logging/src/config.rs +++ b/crates/gateway-logging/src/config.rs @@ -3,10 +3,13 @@ use std::path::{Path, PathBuf}; use std::time::Duration; -/// Previous runs retained beside the current log: `gateway.log.1` (the -/// newest rotation) through `gateway.log.5` (the oldest). A sixth -/// previous run is deleted by the rotation that would create it. -pub(crate) const RETAINED_RUNS: usize = 5; +/// 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. /// @@ -40,7 +43,12 @@ const _: () = { assert!(LOG_LIMITS.producer_wait.as_millis() < LOG_LIMITS.shutdown_wait.as_millis()); assert!( LOG_LIMITS.aggregate_retained_bytes - == LOG_LIMITS.segment_bytes * (RETAINED_RUNS as u64 + 1) + == 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 ); }; @@ -97,10 +105,9 @@ impl LogConfig { self.state_dir.join("logs").join("gateway.log") } - /// The retained previous-run log 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. + /// 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 /// ``` @@ -112,11 +119,11 @@ impl LogConfig { /// ``` #[must_use] pub fn retained_log_paths(&self) -> Vec { - (1..=RETAINED_RUNS) - .map(|run| { + (1..=RETAINED_SEGMENTS) + .map(|segment| { self.state_dir .join("logs") - .join(format!("gateway.log.{run}")) + .join(format!("gateway.log.{segment}")) }) .collect() } @@ -134,7 +141,7 @@ mod tests { use super::*; #[test] - fn the_log_layout_is_current_plus_five_retained_runs() { + fn the_log_layout_is_current_plus_five_numbered_segments() { let config = LogConfig::new("state"); assert_eq!( config.log_path(), diff --git a/crates/gateway-logging/src/worker.rs b/crates/gateway-logging/src/worker.rs index e8a2869e..066d0d6a 100644 --- a/crates/gateway-logging/src/worker.rs +++ b/crates/gateway-logging/src/worker.rs @@ -1,51 +1,665 @@ -//! The worker thread, the file sink with its stderr fallback, and the log -//! rotation performed before the fresh file opens. +//! The worker thread, the segmented file sink with its stderr fallback, +//! and startup plus size-triggered log rotation. -use std::fs::File; -use std::io::{self, BufWriter, Write as _}; +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::{LogConfig, RETAINED_RUNS}; +use crate::config::{LOG_LIMITS, LogConfig, SEGMENT_TRUNCATION_MARKER}; use crate::queue::LogQueue; -/// Opens `/logs/gateway.log` fresh for this run, first shifting -/// the retained chain - `gateway.log.4` to `.5`, down to `gateway.log` to -/// `.1` - and deleting the oldest rotation, so five previous runs are kept -/// and disk use stays bounded. +#[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>, +} + +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 + } + } +} + +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, rotating the -/// existing log, or opening the fresh one. -pub(crate) fn open_log_file(state_dir: &Path) -> io::Result<(PathBuf, File)> { +/// 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(); - if current.is_file() { - // A rename cannot overwrite an existing destination on Windows, so - // the oldest rotation is removed before the chain shifts. - let oldest = &retained[RETAINED_RUNS - 1]; - if oldest.is_file() { - std::fs::remove_file(oldest)?; - } - for run in (1..RETAINED_RUNS).rev() { - if retained[run - 1].is_file() { - std::fs::rename(&retained[run - 1], &retained[run])?; + 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), + } +} + +#[expect( + clippy::unnecessary_wraps, + reason = "directory syncing is supported on Unix and intentionally a no-op elsewhere" +)] +fn sync_parent(path: &Path, fault: &mut FaultInjector) -> io::Result<()> { + #[cfg(unix)] + { + fault.checkpoint("sync parent directory")?; + File::open(path.parent().unwrap_or_else(|| Path::new(".")))?.sync_all() + } + #[cfg(not(unix))] + { + let _ = (path, fault); + 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 copy_durable_file( + source: &Path, + destination: &Path, + fault: &mut FaultInjector, +) -> io::Result<()> { + fault.checkpoint("create staged copy")?; + let mut source = File::open(source)?; + let mut destination = OpenOptions::new() + .write(true) + .create_new(true) + .open(destination)?; + fault.checkpoint("write staged copy")?; + io::copy(&mut source, &mut destination)?; + fault.checkpoint("sync staged copy")?; + destination.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_prepared_path(current: &Path) -> PathBuf { + artifact_path(current, ".rotation-prepared") +} + +fn rotation_committed_path(current: &Path) -> PathBuf { + artifact_path(current, ".rotation-committed") +} + +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_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 cleanup_rotation(current: &Path, retained: &[PathBuf]) -> io::Result<()> { + for target in rotation_targets(current, retained) { + remove_file_if_present(&artifact_path(&target, ".rotation-new"))?; + let backup = artifact_path(&target, ".rotation-old"); + remove_file_if_present(&artifact_path(&backup, ".building"))?; + remove_file_if_present(&backup)?; + } + remove_file_if_present(&rotation_committed_path(current))?; + remove_file_if_present(&rotation_prepared_path(current))?; + sync_parent(current, &mut FaultInjector::default()) +} + +fn rollback_rotation(current: &Path, retained: &[PathBuf], old_mask: u8) -> io::Result<()> { + let targets = rotation_targets(current, retained); + 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))?; + sync_parent(current, &mut FaultInjector::default())?; + remove_file_if_present(&rotation_prepared_path(current))?; + sync_parent(current, &mut FaultInjector::default()) +} + +fn recover_rotation(current: &Path, retained: &[PathBuf]) -> io::Result<()> { + let prepared = rotation_prepared_path(current); + if !prepared.exists() { + return cleanup_rotation(current, retained); + } + if std::fs::read(rotation_committed_path(current)).is_ok_and(|bytes| bytes == b"committed") { + cleanup_rotation(current, retained) + } else { + match read_rotation_mask(&prepared) { + Ok(old_mask) => rollback_rotation(current, retained, old_mask), + Err(_error) + if rotation_targets(current, retained) + .iter() + .all(|target| !artifact_path(target, ".rotation-old").exists()) => + { + cleanup_rotation(current, retained) } + Err(error) => Err(error), + } + } +} + +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 result = (|| { + write_durable_file(&artifact_path(current, ".rotation-new"), b"", fault)?; + for (index, target) in retained.iter().enumerate() { + let source = if index == 0 { + current + } else { + &retained[index - 1] + }; + if existing_file_len(source)?.is_some() { + copy_durable_file(source, &artifact_path(target, ".rotation-new"), fault)?; + } + } + write_durable_file(&rotation_prepared_path(current), &[old_mask], fault)?; + sync_parent(current, fault)?; + for target in &targets { + if existing_file_len(target)?.is_some() { + backup_file(target, &artifact_path(target, ".rotation-old"), fault)?; + } + } + sync_parent(current, fault)?; + for target in retained { + let staged = artifact_path(target, ".rotation-new"); + install_file( + target, + staged.exists().then_some(staged.as_path()), + mode, + fault, + )?; + } + let staged_current = artifact_path(current, ".rotation-new"); + install_file(current, Some(&staged_current), mode, fault)?; + sync_parent(current, fault)?; + write_durable_file(&rotation_committed_path(current), b"committed", fault)?; + sync_parent(current, fault) + })(); + if let Err(error) = result { + if fault.is_simulated_crash() { + return Err(error); + } + return match rollback_rotation(current, retained, old_mask) { + Ok(()) => Err(error), + Err(rollback) => Err(io::Error::other(format!( + "{error}; rotation rollback failed: {rollback}" + ))), + }; + } + cleanup_rotation(current, retained) +} + +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()?; } - std::fs::rename(¤t, &retained[0])?; + 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() } - let file = File::create(¤t)?; - Ok((current, file)) } /// 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. @@ -70,6 +684,16 @@ pub(crate) enum StallPoint { 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!( @@ -105,6 +729,15 @@ impl Sink { 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!( @@ -160,8 +793,8 @@ impl LogWorker { /// /// # Errors /// Returns the I/O failure from spawning the thread. - pub(crate) fn spawn(queue: Arc, file: File) -> io::Result { - Self::spawn_with_sink(queue, Sink::File(BufWriter::new(file))) + 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 { @@ -373,9 +1006,8 @@ mod tests { let baseline = measure_p95_enqueue_to_write(Sink::Null, RECORDS); let temp = TempStateDir::new("latency"); - std::fs::create_dir_all(temp.0.join("logs")).expect("logs dir"); - let file = File::create(temp.0.join("logs/gateway.log")).expect("create the log"); - let file_sink = measure_p95_enqueue_to_write(Sink::File(BufWriter::new(file)), RECORDS); + 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. @@ -480,4 +1112,347 @@ mod tests { "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 crashing_fault(fail_at: usize) -> FaultInjector { + FaultInjector { + fail_at: Some(fail_at), + simulated_crash: true, + ..FaultInjector::default() + } + } + + #[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!( + failures >= 6, + "the loop injected every staged replacement operation" + ); + break; + } + 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_replacement_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 mut fault = crashing_fault(fail_at); + let result = rotate_files( + ¤t, + &retained, + &mut fault, + ReplacementMode::RemoveThenRename, + ); + if result.is_ok() { + completed = true; + assert!( + failures >= 30, + "the loop injected every staged chain operation" + ); + 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; + } + let committed = std::fs::read(rotation_committed_path(¤t)) + .is_ok_and(|bytes| bytes == b"committed"); + if fault.failed_operation == Some("install replacement") + && rotation_targets(¤t, &retained).iter().any(|target| { + !target.exists() && artifact_path(target, ".rotation-old").exists() + }) + { + forced_replacement_gap = true; + } + recover_rotation(¤t, &retained).expect("restart recovers rotation"); + 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_replacement_gap, + "fault injection reaches a Windows remove-then-rename gap" + ); + assert!( + completed, + "the fault loop reaches the first non-failing run" + ); + } + + #[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/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index ab98be38..5b492faa 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -314,7 +314,7 @@ isProject: false - 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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 89f6f783..534e6dca 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -193,3 +193,4 @@ N64 | observation | oversized-unit @ crates/gateway-logging/src/queue.rs::LogQue 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 From d9034d88fb713bf19ce0dc0ee56ec45b0f18ab59 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 11:34:10 -0700 Subject: [PATCH 63/86] Redact logging fields before formatting Suppress classified structured values before their formatters run, then scan bounded formatted text for secrets embedded in messages and dependency errors. Preserve default formatting for ordinary fields. Keep shutdown loss accounting exact across the admission boundary. Stage rotation through renames and empty durable markers so crash recovery does not exceed the aggregate disk budget. - `LogWriter` uses `RedactingVisitor` for every structured field type and delegates unclassified values to `DefaultVisitor`. - `redact_line_bounded` masks Basic and Bearer credentials, cookies, sensitive assignments, URLs, model paths, payloads, prompts, and nested error chains without emitting partial secrets at capacity boundaries. - `admission_gate` tracks active producers and undelivered records in one atomic snapshot, and `abandonment_counts_admission_boundary_record_exactly_once` pins exact shutdown accounting. - `rotate_files` moves segments into rollback positions instead of copying them. Crash-injection tests cover staging, committed cleanup, sparse chains, restart recovery, and directory-byte bounds. Design: replaces oversized-unit @ crates/gateway-logging/src/queue.rs::LogQueue::enqueue_around was: crates/gateway-logging/src/queue.rs::LogQueue::enqueue_after Design: new oversized-unit @ crates/gateway-logging/src/redact.rs Design: new pure-function @ crates/gateway-logging/src/redact.rs::is_sensitive_field deps: &str Design: new pure-function @ crates/gateway-logging/src/redact.rs::sensitive_component_alias deps: &str,&str Design: new pure-function @ crates/gateway-logging/src/redact.rs::redact_line_bounded deps: &str,usize Design: new pure-function @ crates/gateway-logging/src/redact.rs::redact_line deps: &str Design: new pure-function @ crates/gateway-logging/src/redact.rs::find_ascii deps: &str,&str,usize Design: new oversized-unit @ crates/gateway-logging/src/redact.rs::next_sensitive_span Design: new pure-function @ crates/gateway-logging/src/redact.rs::next_sensitive_span deps: &str,usize Design: new pure-function @ crates/gateway-logging/src/redact.rs::starts_ascii deps: &str,&str,usize Design: new pure-function @ crates/gateway-logging/src/redact.rs::assignment_span_at deps: &str,&str,usize Design: new pure-function @ crates/gateway-logging/src/redact.rs::quoted_value_end deps: &[u8],u8,usize Design: new pure-function @ crates/gateway-logging/src/redact.rs::url_span_at deps: &str,usize Design: new pure-function @ crates/gateway-logging/src/redact.rs::local_path_span_at deps: &str,usize Design: new pure-function @ crates/gateway-logging/src/redact.rs::sensitive_token_end deps: &str,usize Design: extends oversized-unit @ crates/gateway-logging/src/worker.rs Design: new pure-function @ crates/gateway-logging/src/worker.rs::rotation_staged_path deps: &Path Design: extends pure-function @ crates/gateway-logging/src/worker.rs::rotation_prepared_path deps: &Path,u8 Design: new pure-function @ crates/gateway-logging/src/worker.rs::legacy_rotation_prepared_path deps: &Path Design: new pure-function @ crates/gateway-logging/src/worker.rs::rotation_source_for deps: &Path,&[PathBuf],usize Design: extends oversized-unit @ crates/gateway-logging/src/worker.rs::live_rotation_recovers_every_injected_filesystem_failure Design: new oversized-unit @ crates/gateway-logging/src/writer.rs Design: new surface-growth @ crates/gateway-logging/src/writer.rs::LogWriter boundary: pub Violates: A2 - credential ownership in gateway logging is not determinable from diff Pending: N62 - compounds Pending: N68 - compounds Plan: vibe/2026-09-07-1-promptforge-debt.md --- crates/gateway-logging/src/lib.rs | 6 +- crates/gateway-logging/src/queue.rs | 133 ++++-- crates/gateway-logging/src/redact.rs | 598 ++++++++++++++++++++------ crates/gateway-logging/src/worker.rs | 323 ++++++++++---- crates/gateway-logging/src/writer.rs | 298 ++++++++++++- crates/gateway/src/main.rs | 4 +- vibe/2026-09-07-1-promptforge-debt.md | 2 +- vibe/archdoc-next.md | 4 +- 8 files changed, 1099 insertions(+), 269 deletions(-) diff --git a/crates/gateway-logging/src/lib.rs b/crates/gateway-logging/src/lib.rs index 29d5d5f8..8e2884a0 100644 --- a/crates/gateway-logging/src/lib.rs +++ b/crates/gateway-logging/src/lib.rs @@ -2,9 +2,9 @@ //! //! [`LogRuntime`] owns one worker thread that drains a bounded priority //! queue into a rotated `gateway.log`; [`LogWriter`] adapts the queue to -//! `tracing-subscriber`'s `MakeWriter` so the binary's fmt layer enqueues -//! byte-bounded formatted events instead of blocking producer threads on -//! disk. +//! `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: diff --git a/crates/gateway-logging/src/queue.rs b/crates/gateway-logging/src/queue.rs index 3ccc8a61..6d5f2563 100644 --- a/crates/gateway-logging/src/queue.rs +++ b/crates/gateway-logging/src/queue.rs @@ -9,7 +9,9 @@ use std::time::{Duration, Instant}; use crate::config::LOG_LIMITS; const ADMISSION_CLOSED: u64 = 1 << 63; -const ACTIVE_PRODUCERS: u64 = ADMISSION_CLOSED - 1; +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; @@ -104,7 +106,6 @@ pub(crate) struct LogQueue { admission_gate: AtomicU64, abandoned: AtomicBool, pending_rejections: AtomicU64, - outstanding_records: AtomicU64, outstanding_summaries: AtomicU64, unreported_pressure_records: AtomicU64, shutdown_abandoned_records: AtomicU64, @@ -357,7 +358,6 @@ impl LogQueue { admission_gate: AtomicU64::new(0), abandoned: AtomicBool::new(false), pending_rejections: AtomicU64::new(0), - outstanding_records: AtomicU64::new(0), outstanding_summaries: AtomicU64::new(0), unreported_pressure_records: AtomicU64::new(0), shutdown_abandoned_records: AtomicU64::new(0), @@ -385,12 +385,15 @@ impl LogQueue { 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 { + 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 + 1, + gate + ACTIVE_PRODUCER_ONE + 1, Ordering::AcqRel, Ordering::Acquire, ) { @@ -400,18 +403,41 @@ impl LogQueue { } } - fn finish_producer(&self) { - let previous = self.admission_gate.fetch_sub(1, Ordering::AcqRel); + 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 + (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) { @@ -473,7 +499,7 @@ impl LogQueue { self.begin_loss(state); state.loss.evicted[record.priority.lane()] = state.loss.evicted[record.priority.lane()].saturating_add(1); - Self::saturating_sub(&self.outstanding_records, 1); + self.subtract_undelivered(1); self.unreported_pressure_records .fetch_add(1, Ordering::AcqRel); } @@ -509,6 +535,17 @@ impl LogQueue { 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; @@ -523,7 +560,7 @@ impl LogQueue { if !self.is_abandoned() { self.record_rejection_without_lock(); } - self.finish_producer(); + self.finish_rejected_producer(); self.work_available.notify_one(); return; }; @@ -531,17 +568,19 @@ impl LogQueue { let mut close_accounted = false; loop { if state.closed { - if !close_accounted { + if close_accounted { + self.finish_admitted_producer(); + } else { self.record_rejection(&mut state); + self.finish_rejected_producer(); } - self.finish_producer(); drop(state); self.work_available.notify_one(); return; } if line_bytes > self.limits.max_bytes { self.record_rejection(&mut state); - self.finish_producer(); + self.finish_rejected_producer(); drop(state); self.work_available.notify_one(); return; @@ -551,8 +590,8 @@ impl LogQueue { self.record_truncation(&mut state); } state.admit(priority, line); - self.outstanding_records.fetch_add(1, Ordering::AcqRel); - self.finish_producer(); + after_admission(); + self.finish_admitted_producer(); drop(state); self.work_available.notify_one(); return; @@ -564,7 +603,7 @@ impl LogQueue { let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { self.record_rejection(&mut state); - self.finish_producer(); + self.finish_rejected_producer(); drop(state); self.work_available.notify_one(); return; @@ -579,7 +618,7 @@ impl LogQueue { close_accounted = state.closed; if timeout.timed_out() && !state.closed { self.record_rejection(&mut state); - self.finish_producer(); + self.finish_rejected_producer(); drop(state); self.work_available.notify_one(); return; @@ -601,20 +640,20 @@ impl LogQueue { if !self.is_abandoned() { self.record_rejection_without_lock(); } - self.finish_producer(); + 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_producer(); + self.finish_rejected_producer(); drop(state); self.work_available.notify_one(); return; } self.record_rejection(&mut state); - self.finish_producer(); + self.finish_rejected_producer(); drop(state); self.work_available.notify_one(); } @@ -753,10 +792,7 @@ impl LogQueue { state.in_flight_pressure_records = state .in_flight_pressure_records .saturating_sub(summary_affected); - Self::saturating_sub( - &self.outstanding_records, - u64::try_from(records).unwrap_or(u64::MAX), - ); + self.subtract_undelivered(u64::try_from(records).unwrap_or(u64::MAX)); if had_summary { Self::saturating_sub(&self.outstanding_summaries, 1); } @@ -776,10 +812,7 @@ impl LogQueue { self.close_admission(); self.abandoned.store(true, Ordering::Release); let loss = ShutdownLoss { - abandoned_records: self - .outstanding_records - .swap(0, Ordering::AcqRel) - .saturating_add(self.active_producers()), + 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), }; @@ -828,6 +861,7 @@ impl LogQueue { } state.closed = true; self.record_rejections(state, state.blocked_producers); + self.subtract_undelivered(state.blocked_producers); } } @@ -1132,6 +1166,49 @@ mod tests { ); } + #[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); diff --git a/crates/gateway-logging/src/redact.rs b/crates/gateway-logging/src/redact.rs index 3646096d..83179a09 100644 --- a/crates/gateway-logging/src/redact.rs +++ b/crates/gateway-logging/src/redact.rs @@ -2,18 +2,110 @@ //! //! No log record may carry credentials, cookies, authorization headers, //! environment values, request bodies, audio, transcript text, prompts, or -//! full local model paths. Most of that list is call-site discipline - -//! the gateway never logs payloads - but the well-shaped secrets (bearer -//! tokens, authorization and cookie header values, `api_key` assignments) -//! can leak through an interpolated error or a debug-formatted structure, -//! so the one chokepoint every record crosses masks them on the way in. +//! 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. -const REDACTED: &str = "[redacted]"; +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)] @@ -34,20 +126,10 @@ impl RedactedLine { } } - fn from_str(text: &str, capacity: usize) -> Self { - let mut bounded = Self::new(capacity); - bounded.push_str(text); - bounded - } - fn capacity(&self) -> usize { self.storage.len() } - fn as_str(&self) -> Result<&str, std::str::Utf8Error> { - std::str::from_utf8(&self.storage[..self.len]) - } - fn push_str(&mut self, text: &str) { let remaining = self.capacity().saturating_sub(self.len); let mut retained = remaining.min(text.len()); @@ -96,13 +178,16 @@ impl RedactedLine { /// 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) -> Option { - let text = RedactedLine::from_str(text, capacity); - let text = redact_header_values(text, "authorization:")?; - let text = redact_header_values(text, "cookie:")?; - let text = redact_header_values(text, "set-cookie:")?; - let text = redact_bearer_tokens(text)?; - redact_api_key_assignments(text) +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. @@ -112,7 +197,7 @@ pub(crate) fn redact_line_bounded(text: &str, capacity: usize) -> Option String { let capacity = text.len().saturating_mul(REDACTED.len()); redact_line_bounded(text, capacity) - .and_then(|redacted| redacted.finish("", false)) + .finish("", false) .filter(|(_, truncated)| !truncated) .map_or_else(String::new, |(text, _)| text.into()) } @@ -121,7 +206,7 @@ pub(crate) fn redact_line(text: &str) -> String { /// `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() { + if from > haystack.len() || needle.len() > haystack.len().saturating_sub(from) { return None; } haystack.as_bytes()[from..] @@ -130,131 +215,206 @@ fn find_ascii(haystack: &str, needle: &str, from: usize) -> Option { .map(|offset| from + offset) } -/// Redacts everything after a header name up to the end of the line: an -/// `Authorization:` or `Cookie:` value runs to the line's end in the -/// one-line-per-event format the fmt layer produces. -fn redact_header_values(input: RedactedLine, header: &str) -> Option { - if find_ascii(input.as_str().ok()?, header, 0).is_none() { - return Some(input); - } - let capacity = input.capacity(); - let inherited_truncation = input.truncated; - let mut out = RedactedLine::new(capacity); - let mut rest = input.as_str().ok()?; - while let Some(start) = find_ascii(rest, header, 0) { - let mut value_start = start + header.len(); - // The conventional space after the colon is kept with the name. - while rest.as_bytes().get(value_start) == Some(&b' ') { - value_start += 1; +#[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, + }); + } + } } - let value_end = rest[value_start..] - .find('\n') - .map_or(rest.len(), |newline| value_start + newline); - out.push_str(&rest[..value_start]); - out.push_str(REDACTED); - rest = &rest[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); } - out.push_str(rest); - out.truncated |= inherited_truncation; - Some(out) + 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())) } -/// Redacts the token after `Bearer `, the shape an authorization value -/// takes when it appears without its header name (an interpolated error, -/// a URL query). The token is the run of non-whitespace following the -/// scheme. -fn redact_bearer_tokens(input: RedactedLine) -> Option { - const SCHEME: &str = "bearer "; - if find_ascii(input.as_str().ok()?, SCHEME, 0).is_none() { - return Some(input); +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 capacity = input.capacity(); - let inherited_truncation = input.truncated; - let mut out = RedactedLine::new(capacity); - let mut rest = input.as_str().ok()?; - let mut from = 0; - while let Some(start) = find_ascii(rest, SCHEME, from) { - let token_start = start + SCHEME.len(); - let token_end = rest[token_start..] - .find(char::is_whitespace) - .map_or(rest.len(), |space| token_start + space); - if token_end == token_start { - from = token_start; - continue; + 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; } - out.push_str(&rest[..token_start]); - out.push_str(REDACTED); - rest = &rest[token_end..]; - from = 0; } - out.push_str(rest); - out.truncated |= inherited_truncation; - Some(out) + text.len() } -/// Redacts the value of an `api_key` assignment in the shapes configs and -/// JSON take: `api_key = "v"`, `api_key="v"`, `"api_key": "v"`, and bare -/// `api_key = v`. The key name is kept so the log still says which field -/// was masked. -fn redact_api_key_assignments(input: RedactedLine) -> Option { - const KEY: &str = "api_key"; - if find_ascii(input.as_str().ok()?, KEY, 0).is_none() { - return Some(input); +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 capacity = input.capacity(); - let inherited_truncation = input.truncated; - let mut out = RedactedLine::new(capacity); - let mut rest = input.as_str().ok()?; - let mut from = 0; - while let Some(start) = find_ascii(rest, KEY, from) { - let after_key = start + KEY.len(); - let bytes = rest.as_bytes(); - let mut cursor = after_key; - // The JSON shape quotes the key: `"api_key": "v"`. - if cursor < bytes.len() && bytes[cursor] == b'"' { - cursor += 1; - } - while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() && bytes[cursor] != b'\n' - { - cursor += 1; - } - // Only an assignment redacts: a bare mention of the field name is - // not a leak. - if cursor >= bytes.len() || (bytes[cursor] != b'=' && bytes[cursor] != b':') { - from = after_key; - continue; - } - cursor += 1; - while cursor < bytes.len() && bytes[cursor].is_ascii_whitespace() && bytes[cursor] != b'\n' - { - cursor += 1; - } - let quoted = cursor < bytes.len() && bytes[cursor] == b'"'; - if quoted { - cursor += 1; - } - let value_start = cursor; - let value_end = if quoted { - rest[value_start..] - .find('"') - .map_or(rest.len(), |quote| value_start + quote) - } else { - rest[value_start..] - .find(|c: char| c.is_whitespace() || c == ',') - .map_or(rest.len(), |end| value_start + end) - }; - if value_end == value_start { - from = after_key; - continue; + 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 }); } - out.push_str(&rest[..value_start]); - out.push_str(REDACTED); - rest = &rest[value_end..]; - from = 0; } - out.push_str(rest); - out.truncated |= inherited_truncation; - Some(out) + 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)] @@ -335,15 +495,167 @@ mod tests { } #[test] - fn an_ordinary_line_passes_through_unchanged() { - let line = "loaded profile main with 2 models; bind 127.0.0.1:8081"; + 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!( - redact_line(line), - line, - "a line without a sensitive shape is byte-identical" + 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"; diff --git a/crates/gateway-logging/src/worker.rs b/crates/gateway-logging/src/worker.rs index 066d0d6a..98fe53ea 100644 --- a/crates/gateway-logging/src/worker.rs +++ b/crates/gateway-logging/src/worker.rs @@ -272,23 +272,6 @@ fn write_durable_file(path: &Path, contents: &[u8], fault: &mut FaultInjector) - file.sync_all() } -fn copy_durable_file( - source: &Path, - destination: &Path, - fault: &mut FaultInjector, -) -> io::Result<()> { - fault.checkpoint("create staged copy")?; - let mut source = File::open(source)?; - let mut destination = OpenOptions::new() - .write(true) - .create_new(true) - .open(destination)?; - fault.checkpoint("write staged copy")?; - io::copy(&mut source, &mut destination)?; - fault.checkpoint("sync staged copy")?; - destination.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() { @@ -385,14 +368,22 @@ fn durable_replace( sync_parent(path, &mut FaultInjector::default()) } -fn rotation_prepared_path(current: &Path) -> PathBuf { - artifact_path(current, ".rotation-prepared") -} - 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()) @@ -412,7 +403,7 @@ fn rotation_old_mask(targets: &[PathBuf]) -> io::Result { }) } -fn read_rotation_mask(path: &Path) -> io::Result { +fn read_legacy_rotation_mask(path: &Path) -> io::Result { let bytes = std::fs::read(path)?; if bytes.len() != 1 { return Err(io::Error::new( @@ -423,20 +414,102 @@ fn read_rotation_mask(path: &Path) -> io::Result { Ok(bytes[0]) } -fn cleanup_rotation(current: &Path, retained: &[PathBuf]) -> io::Result<()> { +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_file_if_present(&artifact_path(&target, ".rotation-new"))?; + remove_rotation_file( + &artifact_path(&target, ".rotation-new"), + "cleanup staged rotation file", + fault, + )?; let backup = artifact_path(&target, ".rotation-old"); - remove_file_if_present(&artifact_path(&backup, ".building"))?; - remove_file_if_present(&backup)?; + 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] } - remove_file_if_present(&rotation_committed_path(current))?; - remove_file_if_present(&rotation_prepared_path(current))?; - sync_parent(current, &mut FaultInjector::default()) } -fn rollback_rotation(current: &Path, retained: &[PathBuf], old_mask: u8) -> io::Result<()> { +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"))?; @@ -451,89 +524,78 @@ fn rollback_rotation(current: &Path, retained: &[PathBuf], old_mask: u8) -> io:: 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(&rotation_prepared_path(current))?; + remove_file_if_present(prepared)?; sync_parent(current, &mut FaultInjector::default()) } fn recover_rotation(current: &Path, retained: &[PathBuf]) -> io::Result<()> { - let prepared = rotation_prepared_path(current); - if !prepared.exists() { + if rotation_committed_path(current).exists() { return cleanup_rotation(current, retained); } - if std::fs::read(rotation_committed_path(current)).is_ok_and(|bytes| bytes == b"committed") { - cleanup_rotation(current, retained) - } else { - match read_rotation_mask(&prepared) { - Ok(old_mask) => rollback_rotation(current, retained, old_mask), - Err(_error) - if rotation_targets(current, retained) - .iter() - .all(|target| !artifact_path(target, ".rotation-old").exists()) => - { - cleanup_rotation(current, retained) - } - Err(error) => Err(error), - } + 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, + _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(&artifact_path(current, ".rotation-new"), b"", fault)?; - for (index, target) in retained.iter().enumerate() { - let source = if index == 0 { - current - } else { - &retained[index - 1] - }; - if existing_file_len(source)?.is_some() { - copy_durable_file(source, &artifact_path(target, ".rotation-new"), fault)?; - } - } - write_durable_file(&rotation_prepared_path(current), &[old_mask], fault)?; + write_durable_file(&prepared, b"", fault)?; sync_parent(current, fault)?; for target in &targets { if existing_file_len(target)?.is_some() { - backup_file(target, &artifact_path(target, ".rotation-old"), fault)?; + fault.checkpoint("stage rotation source")?; + std::fs::rename(target, artifact_path(target, ".rotation-old"))?; } } sync_parent(current, fault)?; - for target in retained { - let staged = artifact_path(target, ".rotation-new"); - install_file( - target, - staged.exists().then_some(staged.as_path()), - mode, - 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"); - install_file(current, Some(&staged_current), mode, fault)?; + fault.checkpoint("install fresh active segment")?; + std::fs::rename(staged_current, current)?; sync_parent(current, fault)?; - write_durable_file(&rotation_committed_path(current), b"committed", fault)?; + write_durable_file(&rotation_committed_path(current), b"", fault)?; sync_parent(current, fault) })(); if let Err(error) = result { if fault.is_simulated_crash() { return Err(error); } - return match rollback_rotation(current, retained, old_mask) { + 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(current, retained) + cleanup_rotation_with(current, retained, fault) } fn prune_oldest_for( @@ -1121,6 +1183,19 @@ mod tests { .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), @@ -1182,7 +1257,7 @@ mod tests { #[test] fn live_rotation_recovers_every_injected_filesystem_failure() { - let mut forced_replacement_gap = false; + let mut forced_staging_gap = false; let mut completed = false; for (failures, fail_at) in (1..=128).enumerate() { let temp = TempStateDir::new("rotation-crash"); @@ -1198,6 +1273,7 @@ mod tests { .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, @@ -1208,9 +1284,13 @@ mod tests { if result.is_ok() { completed = true; assert!( - failures >= 30, + failures >= 20, "the loop injected every staged chain operation" ); + 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!( @@ -1221,16 +1301,23 @@ mod tests { } break; } - let committed = std::fs::read(rotation_committed_path(¤t)) - .is_ok_and(|bytes| bytes == b"committed"); - if fault.failed_operation == Some("install replacement") + assert!( + total_directory_file_bytes(&logs) <= disk_budget, + "transaction artifacts stay inside the aggregate budget at checkpoint {fail_at}" + ); + let committed = rotation_committed_path(¤t).exists(); + if fault.failed_operation == Some("stage rotation source") && rotation_targets(¤t, &retained).iter().any(|target| { !target.exists() && artifact_path(target, ".rotation-old").exists() }) { - forced_replacement_gap = true; + 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() { @@ -1251,8 +1338,8 @@ mod tests { } } assert!( - forced_replacement_gap, - "fault injection reaches a Windows remove-then-rename gap" + forced_staging_gap, + "fault injection reaches an in-place staging boundary with the source preserved" ); assert!( completed, @@ -1260,6 +1347,80 @@ mod tests { ); } + #[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!( + failures >= 5, + "the loop injected every sparse cleanup operation" + ); + } else { + assert!( + fault.failed_operation.is_some(), + "only an injected crash interrupts 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"); diff --git a/crates/gateway-logging/src/writer.rs b/crates/gateway-logging/src/writer.rs index 31a3be98..9146da5c 100644 --- a/crates/gateway-logging/src/writer.rs +++ b/crates/gateway-logging/src/writer.rs @@ -1,14 +1,18 @@ //! 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::redact_line_bounded; +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. @@ -19,7 +23,9 @@ const TRUNCATION_MARKER: &str = " [truncated]\n"; /// /// Priority comes only from the event's tracing metadata; the formatted /// text passes through the privacy redaction before it can reach the -/// queue. Obtained from +/// 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 @@ -27,7 +33,10 @@ const TRUNCATION_MARKER: &str = " [truncated]\n"; /// # 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 _clone = writer.clone(); +/// 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>(()) @@ -58,6 +67,96 @@ impl<'a> MakeWriter<'a> for LogWriter { } } +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. @@ -117,7 +216,7 @@ impl Drop for LogEventWriter { // 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) - .and_then(|redacted| redacted.finish(TRUNCATION_MARKER, self.truncated)) + .finish(TRUNCATION_MARKER, self.truncated) else { self.queue.reject_formatted(); return; @@ -246,7 +345,21 @@ fn finish_formatter_text(buffer: &mut BoundedBytes, truncated: bool) -> Option<& 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() { @@ -286,10 +399,11 @@ mod tests { #[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 = - "api_key=x ".repeat(LOG_LIMITS.max_formatted_record_bytes / "api_key=x ".len()); + let expansion_heavy = UNIT.repeat(LOG_LIMITS.max_formatted_record_bytes / UNIT.len()); let allocations = crate::allocation_tracking::AllocationTracker::start(); { @@ -312,10 +426,12 @@ mod tests { batch.records[0].line.ends_with(TRUNCATION_MARKER), "bounded expansion is explicitly marked" ); - assert!( - !batch.records[0].line.contains("api_key=x"), - "retained assignments are redacted before enqueue" - ); + 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] @@ -558,4 +674,166 @@ mod tests { 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/src/main.rs b/crates/gateway/src/main.rs index d2545b12..480d7ae8 100644 --- a/crates/gateway/src/main.rs +++ b/crates/gateway/src/main.rs @@ -171,9 +171,11 @@ fn init_logging() -> Option { .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(runtime.writer()) + .fmt_fields(file_writer.clone()) + .with_writer(file_writer) .with_filter(filter()); tracing_subscriber::registry() .with(stdout) diff --git a/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index 5b492faa..626d6b72 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -324,7 +324,7 @@ isProject: false - 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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 534e6dca..60d4dadd 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -187,10 +187,10 @@ N58 | observation | constructor-injection @ crates/workshop-server/ui/src/ui/age 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 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 +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 +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 From 16dea69a0b832b7bd5297548edb0fe963e26367a Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 12:09:43 -0700 Subject: [PATCH 64/86] Remove legacy STT configuration shims Make configuration version 2 accept only canonical top-level speech tuning and reject legacy or mixed forms. Remove both parser and browser rewrites so obsolete input cannot cross either persistence boundary. Keep the installed canonical configuration byte-identical, as confirmed by matching read-only SHA-256 checks without exposing its contents. - `migrate_legacy_stt` removes the Rust parser shim, and `canonicalizeStt` removes the TypeScript browser shim in the same change. `ConfigStore` now retains API configuration objects as received and clones pending data without normalization. - `rejects_legacy_stt_section` and `rejects_canonical_and_legacy_stt_sections_together` require unknown-field parse failures, while canonical parser, serializer, and editor tests preserve the supported shape. `check_removed_workshop_stt_claims` rejects guide text that presents the removed section as usable. - `guide/src/gateway/05-speech.md` and the related source, README, and generated guide updates state the version 2 canonical-only contract. - `gateway.toml` remains unchanged and contains no legacy section according to the before-and-after read-only verification. Design: removes shim @ crates/gateway-config/src/config/imp.rs::migrate_legacy_stt deps: &mut toml::Value boundary: persisted Design: removes stringly-typed @ crates/gateway-config/src/config/imp.rs::migrate_legacy_stt deps: &mut toml::Value boundary: persisted Design: removes shim @ crates/gateway-config-ui/ui/src/services/config-store.ts::canonicalizeStt deps: EntryData boundary: persisted Design: removes stringly-typed @ crates/gateway-config-ui/ui/src/services/config-store.ts::canonicalizeStt deps: EntryData boundary: persisted Plan: vibe/2026-09-07-1-promptforge-debt.md --- crates/build-user-guide/src/main.rs | 41 +++++++++++++++++++ .../ui/src/services/config-store.test.mjs | 10 +++++ .../ui/src/services/config-store.ts | 30 +++----------- .../ui/src/views/settings-sections.test.mjs | 11 +++-- crates/gateway-config/README.md | 2 +- crates/gateway-config/src/config/accessors.rs | 3 -- crates/gateway-config/src/config/imp.rs | 28 ------------- .../gateway-config/src/config/tests/schema.rs | 11 ++--- .../src/config/tests/serialize.rs | 6 +-- .../src/config/tests/validation.rs | 21 +++++++++- crates/gateway/README.md | 2 +- guide/promptforge-gateway-guide.md | 4 +- guide/promptforge-workshop-guide.md | 4 +- guide/src/gateway/05-speech.md | 2 +- guide/src/gateway/10-serving-and-observing.md | 2 +- guide/src/workshop/01-application.md | 2 +- guide/src/workshop/07-voice.md | 2 +- vibe/2026-09-07-1-promptforge-debt.md | 2 +- 18 files changed, 100 insertions(+), 83 deletions(-) create mode 100644 crates/gateway-config-ui/ui/src/services/config-store.test.mjs 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/services/config-store.ts b/crates/gateway-config-ui/ui/src/services/config-store.ts index 65172d25..c37a18b9 100644 --- a/crates/gateway-config-ui/ui/src/services/config-store.ts +++ b/crates/gateway-config-ui/ui/src/services/config-store.ts @@ -178,8 +178,8 @@ export class ConfigStore { this.api.getStatus(), this.loadChatTemplates(), ]); - this.running = canonicalizeStt(running); - this.pending = canonicalizeStt(pending); + this.running = running; + this.pending = pending; this.dirty = dirty; this.orphans = visibleOrphans(orphans); this.cache = cache; @@ -201,7 +201,7 @@ export class ConfigStore { this.api.getConfigDirty(), this.loadChatTemplates(), ]); - this.pending = canonicalizeStt(pending); + this.pending = pending; this.dirty = dirty; this.chatTemplates = chatTemplates; } @@ -221,7 +221,7 @@ export class ConfigStore { /** Re-reads the running view too (after apply/revert). */ private async refreshAll(): Promise { const [running, status] = await Promise.all([this.api.getConfig(), this.api.getStatus()]); - this.running = canonicalizeStt(running); + this.running = running; this.activeProfile = status.profile; this.runningModels = status.models; await Promise.all([this.refreshPending(), this.refreshArtifacts()]); @@ -401,12 +401,10 @@ export class ConfigStore { /** * The full `PUT /admin/config` payload base. Untouched secrets remain - * `"***"` so the gateway can restore them before validation. A legacy - * `workshop.stt` value is moved to canonical top-level `stt` before any - * browser save. + * `"***"` so the gateway can restore them before validation. */ buildConfigPayload(): EntryData { - return canonicalizeStt(structuredClone(this.pending)); + return structuredClone(this.pending); } /** Stages the global config and optional active-profile shadow. */ @@ -809,22 +807,6 @@ function modelArray(kind: ModelSource): "model" | "local_model" | "stt_model" { return kind === "local" ? "local_model" : "stt_model"; } -/** Moves legacy `workshop.stt` input into the canonical top-level section. */ -function canonicalizeStt(config: EntryData): EntryData { - const workshop = config["workshop"]; - if (!isRecord(workshop) || !isRecord(workshop["stt"])) { - return config; - } - if (!isRecord(config["stt"])) { - config["stt"] = workshop["stt"]; - } - delete workshop["stt"]; - if (Object.keys(workshop).length === 0) { - delete config["workshop"]; - } - return config; -} - /** Removes ArtifactStore marker files from a gateway response defensively. */ function visibleOrphans(orphans: OrphanFile[]): OrphanFile[] { return orphans.filter((orphan) => !orphan.path.toLocaleLowerCase().endsWith(".verified")); 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 526f32bc..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 @@ -238,16 +238,19 @@ test("Workshop exposes canonical STT tuning without legacy model paths", async ( ); }); -test("a legacy Workshop STT payload is saved only as canonical STT", async () => { +test("a canonical STT payload round-trips through the Workshop editor", async () => { const config = modelsFixture(); - config.workshop = { - stt: { window_seconds: 8, interval_ms: 250, vocabulary: ["WG21"] }, - }; + 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(); diff --git a/crates/gateway-config/README.md b/crates/gateway-config/README.md index e5a1a55f..dfd0c3dc 100644 --- a/crates/gateway-config/README.md +++ b/crates/gateway-config/README.md @@ -44,7 +44,7 @@ Use this canonical section order to minimize merge noise: 11. `[[stt_model]]` 12. `[[profile]]` -Legacy `[workshop.stt]` input migrates to `[stt]` only when the canonical section is absent. Defining both is rejected, and every serialized configuration uses only `[stt]`. +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. diff --git a/crates/gateway-config/src/config/accessors.rs b/crates/gateway-config/src/config/accessors.rs index 9d2a9ff3..c72f67f1 100644 --- a/crates/gateway-config/src/config/accessors.rs +++ b/crates/gateway-config/src/config/accessors.rs @@ -319,9 +319,6 @@ impl Config { } /// Returns canonical `[stt]` pipeline tuning, or `None` when absent. - /// - /// Legacy `[workshop.stt]` input is migrated to this accessor during - /// parsing and is never exposed through [`WorkshopConfig`]. #[must_use] pub fn stt(&self) -> Option<&SttPipelineConfig> { self.stt.as_ref() diff --git a/crates/gateway-config/src/config/imp.rs b/crates/gateway-config/src/config/imp.rs index 51dadb41..b2c200ab 100644 --- a/crates/gateway-config/src/config/imp.rs +++ b/crates/gateway-config/src/config/imp.rs @@ -226,7 +226,6 @@ impl Config { /// parsed TOML document. pub(crate) fn from_value(mut document: toml::Value) -> Result { interpolate_value(&mut document)?; - migrate_legacy_stt(&mut document)?; let raw: RawConfig = document.try_into().map_err(|source| ConfigError::Parse { path: None, source: Box::new(source), @@ -238,33 +237,6 @@ impl Config { } } -fn migrate_legacy_stt(document: &mut toml::Value) -> Result<(), ConfigError> { - let Some(root) = document.as_table_mut() else { - return Ok(()); - }; - let legacy = root - .get_mut("workshop") - .and_then(toml::Value::as_table_mut) - .and_then(|workshop| workshop.remove("stt")); - let Some(legacy) = legacy else { - return Ok(()); - }; - if root.contains_key("stt") { - return Err(ConfigError::Validation( - "[stt] and [workshop.stt] cannot both be present".to_owned(), - )); - } - root.insert("stt".to_owned(), legacy); - if root - .get("workshop") - .and_then(toml::Value::as_table) - .is_some_and(toml::map::Map::is_empty) - { - root.remove("workshop"); - } - Ok(()) -} - pub(crate) fn reject_profiles_directory(path: &Path) -> Result<(), ConfigError> { let profiles = path .parent() diff --git a/crates/gateway-config/src/config/tests/schema.rs b/crates/gateway-config/src/config/tests/schema.rs index 5bb2dc64..0369040a 100644 --- a/crates/gateway-config/src/config/tests/schema.rs +++ b/crates/gateway-config/src/config/tests/schema.rs @@ -75,18 +75,13 @@ fn canonical_example_uses_the_validated_section_layout() { } #[test] -fn canonical_and_legacy_stt_sections_share_one_runtime_shape() { - let canonical = Config::from_toml_str(&format!( +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 legacy = Config::from_toml_str(&format!( - "{CATALOG}\n[workshop.stt]\nwindow_seconds = 8\ninterval_ms = 250\nvocabulary = [\"WG21\"]\n" - )) - .expect("legacy STT section migrates"); - assert_eq!(canonical.stt(), legacy.stt()); - let stt = canonical.stt().expect("canonical STT settings are present"); + 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"]); diff --git a/crates/gateway-config/src/config/tests/serialize.rs b/crates/gateway-config/src/config/tests/serialize.rs index 08b6e5a3..7d6ef323 100644 --- a/crates/gateway-config/src/config/tests/serialize.rs +++ b/crates/gateway-config/src/config/tests/serialize.rs @@ -168,12 +168,12 @@ fn serialized_shape_uses_the_toml_key_names() { } #[test] -fn legacy_stt_input_serializes_only_as_canonical_stt() { +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\ - [workshop.stt]\nwindow_seconds = 8\n", + [stt]\nwindow_seconds = 8\n", ) - .expect("legacy STT input parses"); + .expect("canonical STT input parses"); let json = config.to_json(); assert_eq!(json["stt"]["window_seconds"], 8); diff --git a/crates/gateway-config/src/config/tests/validation.rs b/crates/gateway-config/src/config/tests/validation.rs index 1920f231..e654b544 100644 --- a/crates/gateway-config/src/config/tests/validation.rs +++ b/crates/gateway-config/src/config/tests/validation.rs @@ -196,6 +196,23 @@ 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#" @@ -211,8 +228,8 @@ window_seconds = 8 interval_ms = 250 "#; assert!(matches!( - Config::from_toml_str(toml), - Err(error) if error.kind() == crate::ConfigErrorKind::Validation + Config::parse_toml(toml), + Err(ConfigError::Parse { .. }) )); } diff --git a/crates/gateway/README.md b/crates/gateway/README.md index 68a98a65..743c3bf8 100644 --- a/crates/gateway/README.md +++ b/crates/gateway/README.md @@ -125,7 +125,7 @@ A profile may select at most one interim and one final STT model. Interim withou sources, pins, and interim/final roles live in the global `[[stt_model]]` entries above; the active profile enables them by catalog name. -Legacy `[workshop.stt]` input is accepted only when `[stt]` is absent. Defining both is an error, and config serialization writes only `[stt]`. +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 | |---|---|---| diff --git a/guide/promptforge-gateway-guide.md b/guide/promptforge-gateway-guide.md index 3d73bdea..b61bc30f 100644 --- a/guide/promptforge-gateway-guide.md +++ b/guide/promptforge-gateway-guide.md @@ -420,7 +420,7 @@ 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. -Legacy `[workshop.stt]` input is accepted only when `[stt]` is absent. Defining both is rejected, and saved configuration uses only `[stt]`. +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 @@ -762,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 because its `bind` and `open_browser` settings are inert. Speech pipeline tuning belongs in `[stt]`; legacy `[workshop.stt]` input migrates only when `[stt]` is absent. +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 diff --git a/guide/promptforge-workshop-guide.md b/guide/promptforge-workshop-guide.md index 23269690..cdc17022 100644 --- a/guide/promptforge-workshop-guide.md +++ b/guide/promptforge-workshop-guide.md @@ -60,7 +60,7 @@ The generated config is a single editable TOML file with a header that invites e - 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, and its `bind` and `open_browser` settings do nothing because the Workshop's server now lives inside the desktop application. Speech pipeline tuning belongs in `[stt]`; legacy `[workshop.stt]` input migrates only when `[stt]` is absent. +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. @@ -524,7 +524,7 @@ You can add a `vocabulary` list of domain terms to bias recognition: vocabulary = ["MCP", "GGUF", "Lua"] ```` -Legacy `[workshop.stt]` input is accepted only when `[stt]` is absent. The gateway saves only the canonical `[stt]` form. +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. diff --git a/guide/src/gateway/05-speech.md b/guide/src/gateway/05-speech.md index 54ac36ea..0cb00380 100644 --- a/guide/src/gateway/05-speech.md +++ b/guide/src/gateway/05-speech.md @@ -32,7 +32,7 @@ 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. -Legacy `[workshop.stt]` input is accepted only when `[stt]` is absent. Defining both is rejected, and saved configuration uses only `[stt]`. +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 diff --git a/guide/src/gateway/10-serving-and-observing.md b/guide/src/gateway/10-serving-and-observing.md index db1ce09b..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 because its `bind` and `open_browser` settings are inert. Speech pipeline tuning belongs in `[stt]`; legacy `[workshop.stt]` input migrates only when `[stt]` is absent. +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 diff --git a/guide/src/workshop/01-application.md b/guide/src/workshop/01-application.md index d337d597..0aebdba6 100644 --- a/guide/src/workshop/01-application.md +++ b/guide/src/workshop/01-application.md @@ -56,7 +56,7 @@ The generated config is a single editable TOML file with a header that invites e - 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, and its `bind` and `open_browser` settings do nothing because the Workshop's server now lives inside the desktop application. Speech pipeline tuning belongs in `[stt]`; legacy `[workshop.stt]` input migrates only when `[stt]` is absent. +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. diff --git a/guide/src/workshop/07-voice.md b/guide/src/workshop/07-voice.md index c9b07c5a..a0c01a7a 100644 --- a/guide/src/workshop/07-voice.md +++ b/guide/src/workshop/07-voice.md @@ -54,7 +54,7 @@ You can add a `vocabulary` list of domain terms to bias recognition: vocabulary = ["MCP", "GGUF", "Lua"] ```` -Legacy `[workshop.stt]` input is accepted only when `[stt]` is absent. The gateway saves only the canonical `[stt]` form. +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. diff --git a/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index 626d6b72..646ace24 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -335,7 +335,7 @@ isProject: false - 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 +### 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]`. From c243a6066cc5a7a3eb24f6b26a2a993629e26a13 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 12:28:56 -0700 Subject: [PATCH 65/86] Centralize native STT fixture resolution Route native speech test assets through one feature-gated resolver while each caller keeps its own fallback root. Process environment overrides take precedence, and missing assets fail with a diagnostic that names the resolved path. Default builds do not expose the resolver. - `require_fixture` accepts `&str`, `&Path`, and `&str`, returns `PathBuf`, and replaces resolver copies in five caller roots: `prompt.rs`, `native_whisper.rs`, `test_fixtures/native.rs`, `tests/common/mod.rs`, and `realtime_stt.rs`. - `gateway-stt-engine` exposes the resolver only through `test-fixtures`; direct development dependencies wire the backend and Gateway test targets to it, and the Gateway dependency policy records the test-only edge. - `PROMPTFORGE_WHISPER_LIBRARY`, `PROMPTFORGE_WHISPER_MODEL`, and `PROMPTFORGE_WHISPER_AUDIO` remain caller-selected environment overrides over explicit backend or workspace-local roots. - `feature_boundary.rs` proves caller fallback selection, environment precedence, resolved-path diagnostics, and default feature absence with isolated consumer checks. - `module-ceilings.toml` adds the 24-line engine resolver and raises the measured backend prompt, engine fixture root, and service fixture ceilings. `integration-test-ceilings.json` raises the Gateway Realtime root from 659 to 669 lines and its test total from 18 to 19. - `feature_boundary.rs` adds a 179-line integration test file outside the source module ceilings. Design: extends feature-flag @ crates/gateway-stt-engine/Cargo.toml::test-fixtures Design: extends surface-growth @ crates/gateway-stt-engine/src/test_fixtures.rs::native boundary: pub Design: new hidden-dependency @ crates/gateway-stt-engine/src/test_fixtures/native.rs::require_fixture deps: &Path,&str,&str boundary: pub Design: new stringly-typed @ crates/gateway-stt-engine/src/test_fixtures/native.rs::require_fixture deps: &Path,&str,&str boundary: pub Design: new pure-function @ crates/gateway-stt-backend-whisper/src/prompt.rs::native_fixture_root Design: new pure-function @ crates/gateway-stt/src/test_fixtures/native.rs::native_fixture_root Design: new pure-function @ crates/gateway-stt/tests/common/mod.rs::native_fixture_root Design: new pure-function @ crates/gateway/tests/it/realtime_stt.rs::native_fixture_root Design: removes clone-block @ crates/gateway-stt/src/test_fixtures/native.rs::require_fixture deps: &str,&str Design: removes clone-block @ crates/gateway-stt/tests/common/mod.rs::require_fixture deps: &str,&str Design: new oversized-unit @ crates/gateway-stt-engine/tests/feature_boundary.rs Violates: A2 - credential ownership in crates/gateway-stt/tests/it/architecture.rs is not determinable from diff Pending: N17 - compounds Pending: N19 - compounds Pending: N20 - compounds Plan: vibe/2026-09-07-1-promptforge-debt.md --- Cargo.lock | 2 + crates/gateway-stt-backend-whisper/Cargo.toml | 1 + .../module-ceilings.toml | 2 +- .../gateway-stt-backend-whisper/src/prompt.rs | 36 ++-- .../tests/native_whisper.rs | 56 ++++-- crates/gateway-stt-engine/Cargo.toml | 3 + .../gateway-stt-engine/module-ceilings.toml | 3 +- .../gateway-stt-engine/src/test_fixtures.rs | 3 + .../src/test_fixtures/native.rs | 24 +++ .../tests/feature_boundary.rs | 179 ++++++++++++++++++ crates/gateway-stt/module-ceilings.toml | 2 +- .../gateway-stt/src/test_fixtures/native.rs | 37 ++-- crates/gateway-stt/tests/common/mod.rs | 36 ++-- crates/gateway-stt/tests/it/architecture.rs | 1 + crates/gateway/Cargo.toml | 1 + crates/gateway/tests/it/realtime_stt.rs | 32 ++-- .../gateway/tests/it/realtime_stt/protocol.rs | 7 +- tools/integration-test-ceilings.json | 4 +- vibe/2026-09-07-1-promptforge-debt.md | 2 +- vibe/archdoc-next.md | 11 +- 20 files changed, 347 insertions(+), 95 deletions(-) create mode 100644 crates/gateway-stt-engine/src/test_fixtures/native.rs create mode 100644 crates/gateway-stt-engine/tests/feature_boundary.rs diff --git a/Cargo.lock b/Cargo.lock index 08c5f0ae..74c8f4db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1893,6 +1893,7 @@ dependencies = [ "gateway-logging", "gateway-routing", "gateway-stt", + "gateway-stt-engine", "gateway-web-search", "hound", "ksni", @@ -2046,6 +2047,7 @@ dependencies = [ name = "gateway-stt-engine" version = "0.2.0" dependencies = [ + "tempfile", "thiserror 2.0.19", "tokio", ] diff --git a/crates/gateway-stt-backend-whisper/Cargo.toml b/crates/gateway-stt-backend-whisper/Cargo.toml index 6f4d9a69..2a02d34a 100644 --- a/crates/gateway-stt-backend-whisper/Cargo.toml +++ b/crates/gateway-stt-backend-whisper/Cargo.toml @@ -16,6 +16,7 @@ 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 diff --git a/crates/gateway-stt-backend-whisper/module-ceilings.toml b/crates/gateway-stt-backend-whisper/module-ceilings.toml index 645a91a2..a3bb116e 100644 --- a/crates/gateway-stt-backend-whisper/module-ceilings.toml +++ b/crates/gateway-stt-backend-whisper/module-ceilings.toml @@ -8,4 +8,4 @@ public_root_count = 2 "config.rs" = 32 "lib.rs" = 8 "model.rs" = 293 -"prompt.rs" = 233 +"prompt.rs" = 237 diff --git a/crates/gateway-stt-backend-whisper/src/prompt.rs b/crates/gateway-stt-backend-whisper/src/prompt.rs index e0221e07..8ac96fca 100644 --- a/crates/gateway-stt-backend-whisper/src/prompt.rs +++ b/crates/gateway-stt-backend-whisper/src/prompt.rs @@ -103,32 +103,36 @@ pub(crate) fn final_prompt( 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 require_fixture(variable: &str, fallback: &str) -> PathBuf { - let path = std::env::var_os(variable).map_or_else( - || { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures") - .join(fallback) - }, - PathBuf::from, - ); - assert!( - path.is_file(), - "native test fixture is missing: {}", - path.display() + 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") ); - path } fn require_context() -> WhisperContext { - let library_path = require_fixture("PROMPTFORGE_WHISPER_LIBRARY", "whisper.dll"); - let model_path = require_fixture("PROMPTFORGE_WHISPER_MODEL", "ggml-tiny.en.bin"); + 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") } diff --git a/crates/gateway-stt-backend-whisper/tests/native_whisper.rs b/crates/gateway-stt-backend-whisper/tests/native_whisper.rs index 85d1b00b..99df8396 100644 --- a/crates/gateway-stt-backend-whisper/tests/native_whisper.rs +++ b/crates/gateway-stt-backend-whisper/tests/native_whisper.rs @@ -10,6 +10,7 @@ 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}; @@ -25,19 +26,16 @@ fn fixture_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") } -fn require_fixture(variable: &str, fallback: &str) -> PathBuf { - let path = - std::env::var_os(variable).map_or_else(|| fixture_dir().join(fallback), PathBuf::from); - assert!( - path.is_file(), - "native test fixture is missing: {}", - path.display() +#[test] +fn native_backend_suite_keeps_its_backend_fixture_root() { + assert_eq!( + fixture_dir(), + Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") ); - path } fn jfk_samples() -> Vec { - let path = require_fixture("PROMPTFORGE_WHISPER_AUDIO", "jfk.wav"); + 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"); @@ -80,10 +78,14 @@ fn request( 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", "whisper.dll"); + 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", "ggml-tiny.en.bin"), + require_fixture( + "PROMPTFORGE_WHISPER_MODEL", + &fixture_dir(), + "ggml-tiny.en.bin", + ), &model, ) .expect("copy the exact tiny model fixture"); @@ -168,8 +170,12 @@ async fn packaged_runtime_preserves_native_transcription_contract() { #[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", "whisper.dll"); - let model = require_fixture("PROMPTFORGE_WHISPER_MODEL", "ggml-tiny.en.bin"); + 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(); @@ -188,8 +194,12 @@ async fn independent_final_jobs_do_not_require_a_reset() { #[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", "whisper.dll"); - let model = require_fixture("PROMPTFORGE_WHISPER_MODEL", "ggml-tiny.en.bin"); + 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(); @@ -237,8 +247,12 @@ async fn one_final_job_cannot_change_another_jobs_history() { #[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", "whisper.dll"); - let model = require_fixture("PROMPTFORGE_WHISPER_MODEL", "ggml-tiny.en.bin"); + 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(), "")) @@ -256,8 +270,12 @@ async fn final_decode_is_absent_without_a_final_model() { #[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", "whisper.dll"); - let model = require_fixture("PROMPTFORGE_WHISPER_MODEL", "ggml-tiny.en.bin"); + 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); diff --git a/crates/gateway-stt-engine/Cargo.toml b/crates/gateway-stt-engine/Cargo.toml index bd570185..979ab227 100644 --- a/crates/gateway-stt-engine/Cargo.toml +++ b/crates/gateway-stt-engine/Cargo.toml @@ -13,6 +13,9 @@ description = "Backend-neutral PromptForge speech decoding workers and audio pol thiserror.workspace = true tokio.workspace = true +[dev-dependencies] +tempfile.workspace = true + [features] test-fixtures = [] diff --git a/crates/gateway-stt-engine/module-ceilings.toml b/crates/gateway-stt-engine/module-ceilings.toml index 4dd79aae..439af9dc 100644 --- a/crates/gateway-stt-engine/module-ceilings.toml +++ b/crates/gateway-stt-engine/module-ceilings.toml @@ -11,7 +11,8 @@ public_root_count = 7 "lib.rs" = 18 "policy.rs" = 132 "startup.rs" = 48 -"test_fixtures.rs" = 362 +"test_fixtures.rs" = 365 +"test_fixtures/native.rs" = 24 "test_fixtures/tests.rs" = 304 "translation.rs" = 50 "worker.rs" = 460 diff --git a/crates/gateway-stt-engine/src/test_fixtures.rs b/crates/gateway-stt-engine/src/test_fixtures.rs index 661bd981..b06342fa 100644 --- a/crates/gateway-stt-engine/src/test_fixtures.rs +++ b/crates/gateway-stt-engine/src/test_fixtures.rs @@ -1,5 +1,8 @@ //! Deterministic decoder fixtures for downstream integration tests. +/// Native asset resolution for ignored integration tests. +pub mod native; + use std::collections::VecDeque; use std::sync::{Arc, Condvar, Mutex, PoisonError}; use std::thread::ThreadId; 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/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/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index 0eac67bd..f3e27092 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -46,5 +46,5 @@ public_root_count = 6 "take/window.rs" = 250 "test_fixtures.rs" = 444 "test_fixtures/generation.rs" = 100 -"test_fixtures/native.rs" = 42 +"test_fixtures/native.rs" = 47 "test_fixtures/segment.rs" = 14 diff --git a/crates/gateway-stt/src/test_fixtures/native.rs b/crates/gateway-stt/src/test_fixtures/native.rs index 1fbc8393..d46e7a15 100644 --- a/crates/gateway-stt/src/test_fixtures/native.rs +++ b/crates/gateway-stt/src/test_fixtures/native.rs @@ -7,12 +7,22 @@ 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", "ggml-tiny.en.bin") + 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", "jfk.wav"); + 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"); @@ -24,19 +34,14 @@ pub(crate) fn jfk_samples() -> Vec { .collect() } -fn require_fixture(variable: &str, fallback: &str) -> PathBuf { - let path = std::env::var_os(variable).map_or_else( - || { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../gateway-stt-backend-whisper/tests/fixtures") - .join(fallback) - }, - PathBuf::from, - ); - assert!( - path.is_file(), - "native test fixture is missing: {}", - path.display() +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") ); - path } diff --git a/crates/gateway-stt/tests/common/mod.rs b/crates/gateway-stt/tests/common/mod.rs index 0ea85123..bbaba98a 100644 --- a/crates/gateway-stt/tests/common/mod.rs +++ b/crates/gateway-stt/tests/common/mod.rs @@ -10,14 +10,23 @@ 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", "ggml-tiny.en.bin") + 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", "jfk.wav"); + 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"); @@ -29,21 +38,16 @@ pub(crate) fn jfk_samples() -> Vec { .collect() } -fn require_fixture(variable: &str, fallback: &str) -> PathBuf { - let path = std::env::var_os(variable).map_or_else( - || { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../gateway-stt-backend-whisper/tests/fixtures") - .join(fallback) - }, - PathBuf::from, - ); - assert!( - path.is_file(), - "native test fixture is missing: {}", - path.display() +fn native_fixture_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../gateway-stt-backend-whisper/tests/fixtures") +} + +#[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") ); - path } pub(crate) fn fixture_service(with_final: bool) -> SpeechService { diff --git a/crates/gateway-stt/tests/it/architecture.rs b/crates/gateway-stt/tests/it/architecture.rs index bae1fb50..feeb93d7 100644 --- a/crates/gateway-stt/tests/it/architecture.rs +++ b/crates/gateway-stt/tests/it/architecture.rs @@ -54,6 +54,7 @@ const DEPENDENCY_POLICIES: [DependencyPolicy; 7] = [ "gateway-logging", "gateway-routing", "gateway-stt", + "gateway-stt-engine", "gateway-web-search", "promptforge-core", "shared-loopback", diff --git a/crates/gateway/Cargo.toml b/crates/gateway/Cargo.toml index 193939c9..1478643c 100644 --- a/crates/gateway/Cargo.toml +++ b/crates/gateway/Cargo.toml @@ -143,6 +143,7 @@ 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/tests/it/realtime_stt.rs b/crates/gateway/tests/it/realtime_stt.rs index 387b0343..f446aa97 100644 --- a/crates/gateway/tests/it/realtime_stt.rs +++ b/crates/gateway/tests/it/realtime_stt.rs @@ -12,6 +12,7 @@ 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 _; @@ -183,19 +184,24 @@ fn audio_samples(samples: &[i16]) -> String { base64::engine::general_purpose::STANDARD.encode(bytes) } -fn native_fixture(variable: &str, name: &str) -> PathBuf { - std::env::var_os(variable).map_or_else( - || { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../local/stt-fixtures") - .join(name) - }, - PathBuf::from, - ) +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 = native_fixture("PROMPTFORGE_WHISPER_AUDIO", "jfk.wav"); + 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); @@ -216,7 +222,11 @@ fn native_jfk_24khz() -> Vec { } fn native_speech_service() -> SpeechService { - let model = native_fixture("PROMPTFORGE_WHISPER_MODEL", "ggml-tiny.en.bin"); + 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"); diff --git a/crates/gateway/tests/it/realtime_stt/protocol.rs b/crates/gateway/tests/it/realtime_stt/protocol.rs index f700ca80..a5cc2df4 100644 --- a/crates/gateway/tests/it/realtime_stt/protocol.rs +++ b/crates/gateway/tests/it/realtime_stt/protocol.rs @@ -69,12 +69,7 @@ async fn realtime_stt_native_incremental() { ("PROMPTFORGE_WHISPER_MODEL", "ggml-tiny.en.bin"), ("PROMPTFORGE_WHISPER_AUDIO", "jfk.wav"), ] { - let path = native_fixture(variable, name); - assert!( - path.is_file(), - "native test fixture is missing: {}", - path.display() - ); + let _fixture = require_fixture(variable, &native_fixture_root(), name); } let service = native_speech_service(); let server = server(true, &service).await; diff --git a/tools/integration-test-ceilings.json b/tools/integration-test-ceilings.json index 83cd5da0..1344663d 100644 --- a/tools/integration-test-ceilings.json +++ b/tools/integration-test-ceilings.json @@ -2,10 +2,10 @@ "version": 1, "suites": { "crates/gateway/tests/it/realtime_stt": { - "testTotal": 18, + "testTotal": 19, "entry": { "path": "crates/gateway/tests/it/realtime_stt.rs", - "ceiling": 659 + "ceiling": 669 }, "files": { "authentication.rs": 89, diff --git a/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index 646ace24..76b043c8 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -346,7 +346,7 @@ isProject: false - 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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 60d4dadd..6ce2611f 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -140,12 +140,12 @@ N11 | observation | flag-parameter @ crates/gateway-stt-backend-whisper/src/mode 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 -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 -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 +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 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 -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 +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 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 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 @@ -194,3 +194,4 @@ N65 | observation | oversized-unit @ crates/gateway-logging/src/runtime.rs::asse 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 From fdb644883ffc2e448553b32f422956b4c28ebb75 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 12:52:31 -0700 Subject: [PATCH 66/86] Ratchet feature-enabled STT fixture APIs Freeze both ordinary and test-enabled speech fixture surfaces before later contraction. Compare generated public APIs with exact snapshots and root-count records so drift or unusable tool output stops the architecture gate. - `crates/gateway-stt/public-api-default.txt` and `crates/gateway-stt-engine/public-api-default.txt` record the exact default surfaces used to prove fixture symbols stay absent. - `crates/gateway-stt/public-api-test-fixtures.txt` and `crates/gateway-stt-engine/public-api-test-fixtures.txt` record the exact surfaces exposed with `test-fixtures`. - `runPublicApi` keeps `cargo +nightly-2026-09-05 public-api` pinned to each crate manifest and package, and enables only `test-fixtures` for feature snapshots. - `test_fixture_public_root_count` records exact feature-enabled root counts of 7 for `gateway-stt` and 8 for `gateway-stt-engine` in both ceiling manifests and the Rust architecture gate. - `canonicalPublicApi` normalizes CRLF to LF, rejects bare CR, empty output, missing final newlines, malformed records, additions, removals, missing tools, and failed tool invocations. - `public-api-test-fixtures.txt` establishes a measured baseline only. This change does not narrow existing fixture controls or alter production API definitions. Design: new global-state @ tools/check-stt-architecture.mjs::FIXTURE_STT_CRATES Design: new pure-function @ tools/check-stt-architecture.mjs::testFixturePublicRootCount deps: crateName,source boundary: pub Design: new surface-growth @ tools/check-stt-architecture.mjs::testFixturePublicRootCount deps: crateName,source boundary: pub Design: new pure-function @ tools/check-stt-architecture.mjs::canonicalPublicApi deps: crateName,output Design: new pure-function @ tools/check-stt-architecture.mjs::requireExactPublicApi deps: actual,crateName,expected boundary: pub Design: new surface-growth @ tools/check-stt-architecture.mjs::requireExactPublicApi deps: actual,crateName,expected boundary: pub Design: new pure-function @ tools/check-stt-architecture.mjs::requireNoFixtureApi deps: crateName,expected,output boundary: pub Design: new surface-growth @ tools/check-stt-architecture.mjs::requireNoFixtureApi deps: crateName,expected,output boundary: pub Design: new surface-growth @ tools/check-stt-architecture.mjs::runPublicApi deps: crateName,env,features,root,spawn boundary: pub Design: new oversized-unit @ tools/check-stt-architecture.mjs::main Design: new oversized-unit @ crates/gateway-stt/public-api-test-fixtures.txt Design: new oversized-unit @ crates/gateway-stt-engine/public-api-test-fixtures.txt Design: new pure-function @ crates/gateway-stt/tests/it/architecture.rs::expected_test_fixture_public_root_count deps: &str Violates: A2 - credential ownership in crates/gateway-stt/tests/it/architecture.rs is not determinable from diff Pending: N17 - compounds Plan: vibe/2026-09-07-1-promptforge-debt.md --- .../gateway-stt-engine/module-ceilings.toml | 1 + .../gateway-stt-engine/public-api-default.txt | 63 ++++++++ .../public-api-test-fixtures.txt | 101 +++++++++++++ crates/gateway-stt/module-ceilings.toml | 1 + crates/gateway-stt/public-api-default.txt | 63 ++++++++ .../gateway-stt/public-api-test-fixtures.txt | 129 ++++++++++++++++ crates/gateway-stt/tests/it/architecture.rs | 15 ++ tools/check-stt-architecture.mjs | 93 +++++++++++- tools/check-stt-architecture.test.mjs | 140 ++++++++++++++++++ vibe/2026-09-07-1-promptforge-debt.md | 2 +- vibe/archdoc-next.md | 2 +- 11 files changed, 607 insertions(+), 3 deletions(-) create mode 100644 crates/gateway-stt-engine/public-api-default.txt create mode 100644 crates/gateway-stt-engine/public-api-test-fixtures.txt create mode 100644 crates/gateway-stt/public-api-default.txt create mode 100644 crates/gateway-stt/public-api-test-fixtures.txt diff --git a/crates/gateway-stt-engine/module-ceilings.toml b/crates/gateway-stt-engine/module-ceilings.toml index 439af9dc..336ac2d2 100644 --- a/crates/gateway-stt-engine/module-ceilings.toml +++ b/crates/gateway-stt-engine/module-ceilings.toml @@ -3,6 +3,7 @@ # 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 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..ff0cefd0 --- /dev/null +++ b/crates/gateway-stt-engine/public-api-test-fixtures.txt @@ -0,0 +1,101 @@ +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::park_construction(&self) +pub fn gateway_stt_engine::test_fixtures::ScriptedDecoder::park_next(&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::release(&self) +pub fn gateway_stt_engine::test_fixtures::ScriptedDecoder::release_construction(&self) +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_construction_parked(&self, core::time::Duration) -> bool +pub fn gateway_stt_engine::test_fixtures::ScriptedDecoder::wait_until_parked(&self, core::time::Duration) -> bool +pub fn gateway_stt_engine::test_fixtures::ScriptedDecoder::wait_until_worker_dropped(&self, core::time::Duration) -> bool +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_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/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index f3e27092..6f37e146 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -3,6 +3,7 @@ # 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 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..d7923c20 --- /dev/null +++ b/crates/gateway-stt/public-api-test-fixtures.txt @@ -0,0 +1,129 @@ +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::RealtimeInterimEpoch(_) +pub struct gateway_stt::test_fixtures::RealtimeSessionFixture +impl gateway_stt::test_fixtures::RealtimeSessionFixture +pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::accept_interim(&mut self, gateway_stt::test_fixtures::RealtimeInterimEpoch, alloc::string::String) -> core::result::Result, serde_json::error::Error> +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 fn gateway_stt::test_fixtures::RealtimeSessionFixture::begin_interim(&mut self) -> core::result::Result +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 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/tests/it/architecture.rs b/crates/gateway-stt/tests/it/architecture.rs index feeb93d7..4290dec5 100644 --- a/crates/gateway-stt/tests/it/architecture.rs +++ b/crates/gateway-stt/tests/it/architecture.rs @@ -20,6 +20,9 @@ const PUBLIC_ROOT_COUNTS: [(&str, usize); 4] = [ ("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", @@ -113,6 +116,7 @@ const DEPENDENCY_POLICIES: [DependencyPolicy; 7] = [ #[serde(deny_unknown_fields)] struct CeilingsFile { public_root_count: usize, + test_fixture_public_root_count: Option, modules: BTreeMap, } @@ -487,6 +491,12 @@ fn expected_public_root_count(crate_name: &str) -> usize { .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!( @@ -511,6 +521,11 @@ fn final_module_ceilings_cover_every_source() { 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| { diff --git a/tools/check-stt-architecture.mjs b/tools/check-stt-architecture.mjs index fa7b6e79..6d5a24aa 100644 --- a/tools/check-stt-architecture.mjs +++ b/tools/check-stt-architecture.mjs @@ -14,6 +14,7 @@ const STT_CRATES = [ "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); @@ -224,6 +225,20 @@ export function publicRootCount(source, crateName) { 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( @@ -232,6 +247,35 @@ export function requireExactPublicRootCount(crateName, actual, 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, @@ -282,9 +326,11 @@ export function runRustdocCargo( export function runPublicApi( root, crateName, - { spawn = spawnSync, env = process.env } = {}, + { 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, [ @@ -293,6 +339,7 @@ export function runPublicApi( manifestPath, "--package", crateName, + ...featureArgs, "-sss", "--color", "never", @@ -350,6 +397,50 @@ function main() { 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}`, + ); + } } } diff --git a/tools/check-stt-architecture.test.mjs b/tools/check-stt-architecture.test.mjs index 8fa9b870..b3386518 100644 --- a/tools/check-stt-architecture.test.mjs +++ b/tools/check-stt-architecture.test.mjs @@ -7,8 +7,11 @@ import { countEffectiveRootNames, parseCargoModulesDot, publicRootCount, + requireExactPublicApi, requireCargoVersion, requireExactPublicRootCount, + requireNoFixtureApi, + testFixturePublicRootCount, requireToolVersion, runCargo, runPublicApi, @@ -108,6 +111,114 @@ test("public root count is exact rather than a spare budget", () => { ); }); +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"), @@ -185,6 +296,35 @@ test("public API command keeps exact package selection", () => { 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( () => diff --git a/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index 76b043c8..f154b954 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -356,7 +356,7 @@ isProject: false - 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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 6ce2611f..e042f665 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -142,7 +142,7 @@ N13 | observation | global-state @ crates/gateway-stt-backend-whisper/src/prompt 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 +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 From 060626c456a3626d972db45cc19f310508618314 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 13:25:22 -0700 Subject: [PATCH 67/86] Narrow STT fixture controls to scenarios Contract feature-gated speech fixtures around bounded scenario operations so consumers no longer coordinate raw synchronization phases. Scoped decode and construction flows preserve behavior and guarantee release and joined cleanup after completion, cancellation, timeout, or panic. - `ScriptedDecoder::with_next_decode_blocked` and `ScriptedModelFactory::with_construction_blocked` replace six public park, wait, and release methods with two bounded operations. The exact fixture snapshots contract from 101 to 97 lines and from 129 to 127 lines. - `scenarios`, `scenario_cleanup`, `scheduling.rs`, and `capacity.rs` split fixture mechanics and integration coverage through one-way parent-to-child wiring. Updated ratchets lower the parent fixture and Realtime suite ceilings and record every new boundary. - `RealtimeSessionFixture::accept_interim_across_clear` owns epoch creation, clear, and stale acceptance, while `spawn_interim` stops returning the raw epoch. - `gateway-stt-engine` cleanup tests, `gateway-stt` generation and session tests, and Gateway provisioning and Realtime tests use the scoped operations. Cleanup coverage proves normal, canceled, timed-out, and panicked scenarios release blocked work and permit deterministic follow-up construction or decoding. Design: removes temporal-coupling @ crates/gateway-stt-engine/src/test_fixtures.rs::ScriptedDecoder Design: replaces shared-mutable-state @ crates/gateway-stt-engine/src/test_fixtures/scenarios.rs::ScriptedDecoder was: crates/gateway-stt-engine/src/test_fixtures.rs::ScriptedDecoder Design: replaces oversized-unit @ crates/gateway-stt-engine/src/test_fixtures/scenarios.rs::ScriptedDecoder was: crates/gateway-stt-engine/src/test_fixtures.rs::ScriptedDecoder Design: new oversized-unit @ crates/gateway-stt-engine/src/test_fixtures/tests/scenario_cleanup/construction.rs Design: new oversized-unit @ crates/gateway-stt-engine/src/test_fixtures/tests/scenario_cleanup/decode.rs Design: removes surface-growth @ crates/gateway-stt/src/test_fixtures.rs::RealtimeInterimEpoch boundary: pub Design: replaces oversized-unit @ crates/gateway/tests/it/realtime_stt/capacity.rs::saturated_commit_preserves_the_canonical_input_for_retry was: crates/gateway/tests/it/realtime_stt/overload.rs::saturated_commit_preserves_the_canonical_input_for_retry Design: replaces oversized-unit @ crates/gateway/tests/it/realtime_stt/scheduling.rs::interim_scheduler_enforces_cadence_minimum_silence_and_coalescing was: crates/gateway/tests/it/realtime_stt/lifecycle.rs::interim_scheduler_enforces_cadence_minimum_silence_and_coalescing Violates: A2 - credential ownership in Gateway speech fixture changes is not determinable from diff Plan: vibe/2026-09-07-1-promptforge-debt.md --- .../gateway-stt-engine/module-ceilings.toml | 8 +- .../public-api-test-fixtures.txt | 8 +- .../gateway-stt-engine/src/test_fixtures.rs | 328 +++++------------- .../src/test_fixtures/scenarios.rs | 294 ++++++++++++++++ .../src/test_fixtures/tests.rs | 66 +--- .../test_fixtures/tests/scenario_cleanup.rs | 56 +++ .../tests/scenario_cleanup/construction.rs | 100 ++++++ .../tests/scenario_cleanup/decode.rs | 150 ++++++++ .../tests/startup_cleanup.rs | 44 ++- crates/gateway-stt/module-ceilings.toml | 2 +- .../gateway-stt/public-api-test-fixtures.txt | 6 +- crates/gateway-stt/src/realtime/mod.rs | 2 +- crates/gateway-stt/src/test_fixtures.rs | 52 ++- crates/gateway-stt/tests/it/generation.rs | 202 +++++------ .../gateway-stt/tests/it/realtime_session.rs | 196 ++++++----- crates/gateway/src/lib.rs | 34 +- crates/gateway/tests/it/realtime_stt.rs | 214 +++--------- .../gateway/tests/it/realtime_stt/capacity.rs | 207 +++++++++++ .../tests/it/realtime_stt/lifecycle.rs | 184 +++------- .../gateway/tests/it/realtime_stt/overload.rs | 97 ------ .../gateway/tests/it/realtime_stt/recovery.rs | 102 +++--- .../tests/it/realtime_stt/scheduling.rs | 90 +++++ tools/integration-test-ceilings.json | 12 +- vibe/2026-09-07-1-promptforge-debt.md | 2 +- vibe/archdoc-next.md | 5 +- 25 files changed, 1428 insertions(+), 1033 deletions(-) create mode 100644 crates/gateway-stt-engine/src/test_fixtures/scenarios.rs create mode 100644 crates/gateway-stt-engine/src/test_fixtures/tests/scenario_cleanup.rs create mode 100644 crates/gateway-stt-engine/src/test_fixtures/tests/scenario_cleanup/construction.rs create mode 100644 crates/gateway-stt-engine/src/test_fixtures/tests/scenario_cleanup/decode.rs create mode 100644 crates/gateway/tests/it/realtime_stt/capacity.rs create mode 100644 crates/gateway/tests/it/realtime_stt/scheduling.rs diff --git a/crates/gateway-stt-engine/module-ceilings.toml b/crates/gateway-stt-engine/module-ceilings.toml index 336ac2d2..1636f4df 100644 --- a/crates/gateway-stt-engine/module-ceilings.toml +++ b/crates/gateway-stt-engine/module-ceilings.toml @@ -12,8 +12,12 @@ test_fixture_public_root_count = 8 "lib.rs" = 18 "policy.rs" = 132 "startup.rs" = 48 -"test_fixtures.rs" = 365 +"test_fixtures.rs" = 201 "test_fixtures/native.rs" = 24 -"test_fixtures/tests.rs" = 304 +"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-test-fixtures.txt b/crates/gateway-stt-engine/public-api-test-fixtures.txt index ff0cefd0..be291305 100644 --- a/crates/gateway-stt-engine/public-api-test-fixtures.txt +++ b/crates/gateway-stt-engine/public-api-test-fixtures.txt @@ -10,23 +10,19 @@ pub fn gateway_stt_engine::test_fixtures::ScriptedDecoder::fail_next_constructio 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::park_construction(&self) -pub fn gateway_stt_engine::test_fixtures::ScriptedDecoder::park_next(&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::release(&self) -pub fn gateway_stt_engine::test_fixtures::ScriptedDecoder::release_construction(&self) 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_construction_parked(&self, core::time::Duration) -> bool -pub fn gateway_stt_engine::test_fixtures::ScriptedDecoder::wait_until_parked(&self, 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 diff --git a/crates/gateway-stt-engine/src/test_fixtures.rs b/crates/gateway-stt-engine/src/test_fixtures.rs index b06342fa..911dcc61 100644 --- a/crates/gateway-stt-engine/src/test_fixtures.rs +++ b/crates/gateway-stt-engine/src/test_fixtures.rs @@ -3,256 +3,19 @@ /// Native asset resolution for ignored integration tests. pub mod native; -use std::collections::VecDeque; -use std::sync::{Arc, Condvar, Mutex, PoisonError}; -use std::thread::ThreadId; -use std::time::Duration; +use std::sync::mpsc::{TryRecvError, sync_channel}; +use std::time::{Duration, Instant}; -use crate::{DecodeMode, DecodeRequest, Decoder, ModelFactory, TranscribeError}; +use crate::{DecodeMode, Decoder, ModelFactory, 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)>, -} - -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); - } - - /// Parks the next decode until [`Self::release`] is called. - pub fn park_next(&self) { - self.state().park = ParkState::Armed; - } - - /// Parks decoder construction until [`Self::release_construction`] runs. - pub fn park_construction(&self) { - self.state().construction = ConstructionState::Armed; - } - - /// 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()); - } - - /// Releases a decode parked by [`Self::park_next`]. - pub fn release(&self) { - let (_, changed) = &*self.shared; - self.state().park = ParkState::Released; - changed.notify_all(); - } - - /// Releases construction parked by [`Self::park_construction`]. - pub fn release_construction(&self) { - let (_, changed) = &*self.shared; - self.state().construction = ConstructionState::Released; - changed.notify_all(); - } - - /// 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) - } +mod scenarios; +pub use scenarios::ScriptedDecoder; - /// Waits until a parked decode has entered its rendezvous. - #[must_use] - pub fn wait_until_parked(&self, timeout: Duration) -> bool { - self.wait_for(timeout, |state| state.park == ParkState::Parked) - } +struct ConstructionBlock(ScriptedDecoder); - /// Waits until decoder construction has entered its rendezvous. - #[must_use] - pub fn wait_until_construction_parked(&self, timeout: Duration) -> bool { - self.wait_for(timeout, |state| { - state.construction == ConstructionState::Parked - }) - } - - /// 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) - } - - 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) - } - - 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()); - } - - fn take_construction_error(&self) -> Option { - self.state().construction_errors.pop_front() - } -} - -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 { +impl Drop for ConstructionBlock { 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"); + self.0.release_construction(); } } @@ -330,6 +93,72 @@ impl ScriptedModelFactory { 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 { @@ -357,9 +186,16 @@ impl ModelFactory for ScriptedModelFactory { return Err(TranscribeError::InvalidConfig(message)); } decoder.mark_created(); - Ok(Some(Box::new(WorkerDecoder(decoder.clone())))) + 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/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 index abe5359c..348a0ae0 100644 --- a/crates/gateway-stt-engine/src/test_fixtures/tests.rs +++ b/crates/gateway-stt-engine/src/test_fixtures/tests.rs @@ -1,5 +1,7 @@ use super::*; -use crate::{EnginePolicy, SttEngine}; +use crate::{DecodeRequest, EnginePolicy, SttEngine}; + +mod scenario_cleanup; fn policy() -> EnginePolicy { EnginePolicy::new(15, 500, false).expect("test policy is valid") @@ -22,13 +24,8 @@ fn assert_invalid_config(error: TranscribeError, expected: &str) { } fn wait_until_waiter_is_registered(decoder: &ScriptedDecoder) { - let (state, changed) = &*decoder.shared; - let state = state.lock().unwrap_or_else(PoisonError::into_inner); - let (state, timeout) = changed - .wait_timeout_while(state, Duration::from_secs(1), |state| state.waiters == 0) - .unwrap_or_else(PoisonError::into_inner); assert!( - !timeout.timed_out() && state.waiters == 1, + decoder.wait_until_waiter_registered(Duration::from_secs(1)), "request waiter must enter the condition-variable wait" ); } @@ -230,61 +227,6 @@ fn scripted_final_factory_error_reaches_the_constructor_and_cleans_up_interim() assert!(!final_decoder.worker_dropped()); } -#[test] -fn parked_interim_construction_has_a_bounded_classified_outcome() { - let interim = ScriptedDecoder::new(); - interim.park_construction(); - let factory = ScriptedModelFactory::new(interim.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 || { - let result = SttEngine::new(factory, timeout); - drop(result_tx.send(result)); - }); - assert!(interim.wait_until_construction_parked(Duration::from_secs(1))); - let error = result_rx - .recv_timeout(Duration::from_secs(1)) - .expect("startup returns by its deadline") - .expect_err("parked interim construction times out"); - assert!(matches!(error, TranscribeError::InterimStartupTimedOut)); - constructor.join().expect("constructor does not panic"); - interim.release_construction(); - assert!(interim.wait_for(Duration::from_secs(1), |state| state.worker_dropped)); -} - -#[test] -fn parked_final_construction_cleans_up_the_initialized_interim_worker() { - let interim = ScriptedDecoder::new(); - let final_decoder = ScriptedDecoder::new(); - final_decoder.park_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 || { - let result = SttEngine::new(factory, timeout); - drop(result_tx.send(result)); - }); - assert!( - final_decoder.wait_until_construction_parked(Duration::from_secs(1)), - "final construction reaches its deterministic park" - ); - let error = result_rx - .recv_timeout(Duration::from_secs(1)) - .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_for(Duration::from_secs(1), |state| state.worker_dropped), - "the abandoned constructor releases its decoder after returning" - ); -} - #[test] fn shutdown_surfaces_join_panic_and_remains_idempotent() { let interim = ScriptedDecoder::new(); 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/tests/startup_cleanup.rs b/crates/gateway-stt-engine/tests/startup_cleanup.rs index 5a288d66..8bf89c91 100644 --- a/crates/gateway-stt-engine/tests/startup_cleanup.rs +++ b/crates/gateway-stt-engine/tests/startup_cleanup.rs @@ -143,33 +143,29 @@ fn final_first_startup_failure_preserves_interim_cleanup_panic() { #[test] fn both_workers_start_concurrently_under_one_absolute_deadline() { let interim = ScriptedDecoder::new(); - interim.park_construction(); let final_decoder = ScriptedDecoder::new(); - final_decoder.park_construction(); let factory = ScriptedModelFactory::new(interim.clone()).with_final(final_decoder.clone()); let policy = policy().with_startup_timeout(Duration::from_millis(200)); - let (result_tx, result_rx) = std::sync::mpsc::channel(); - let constructor = std::thread::spawn(move || { - let result = SttEngine::new(factory, policy); - drop(result_tx.send(result)); - }); - - assert!( - interim.wait_until_construction_parked(Duration::from_secs(1)), - "interim construction reaches its rendezvous" - ); - assert!( - final_decoder.wait_until_construction_parked(Duration::from_secs(1)), - "final construction starts before the interim outcome is available" - ); - let result = result_rx.recv_timeout(Duration::from_millis(350)); - interim.release_construction(); - final_decoder.release_construction(); - constructor.join().expect("constructor does not panic"); - - let error = result - .expect("both outcomes share the original 200 ms deadline") - .expect_err("both parked workers time out"); + 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"); }; diff --git a/crates/gateway-stt/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index 6f37e146..dad48f9d 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -45,7 +45,7 @@ test_fixture_public_root_count = 7 "take/state.rs" = 157 "take/text.rs" = 9 "take/window.rs" = 250 -"test_fixtures.rs" = 444 +"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-test-fixtures.txt b/crates/gateway-stt/public-api-test-fixtures.txt index d7923c20..087898f2 100644 --- a/crates/gateway-stt/public-api-test-fixtures.txt +++ b/crates/gateway-stt/public-api-test-fixtures.txt @@ -20,13 +20,11 @@ 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::RealtimeInterimEpoch(_) pub struct gateway_stt::test_fixtures::RealtimeSessionFixture impl gateway_stt::test_fixtures::RealtimeSessionFixture -pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::accept_interim(&mut self, gateway_stt::test_fixtures::RealtimeInterimEpoch, alloc::string::String) -> core::result::Result, serde_json::error::Error> +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 fn gateway_stt::test_fixtures::RealtimeSessionFixture::begin_interim(&mut self) -> core::result::Result 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 @@ -47,7 +45,7 @@ pub fn gateway_stt::test_fixtures::RealtimeSessionFixture::push_delta(&mut self, 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 where F: core::future::future::Future + core::marker::Send + 'static +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 diff --git a/crates/gateway-stt/src/realtime/mod.rs b/crates/gateway-stt/src/realtime/mod.rs index 7e4d7ebc..951fab8e 100644 --- a/crates/gateway-stt/src/realtime/mod.rs +++ b/crates/gateway-stt/src/realtime/mod.rs @@ -13,4 +13,4 @@ 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::{InterimEpoch, Session}; +pub(crate) use session::Session; diff --git a/crates/gateway-stt/src/test_fixtures.rs b/crates/gateway-stt/src/test_fixtures.rs index f403586a..485a8bb7 100644 --- a/crates/gateway-stt/src/test_fixtures.rs +++ b/crates/gateway-stt/src/test_fixtures.rs @@ -4,7 +4,7 @@ use std::future::Future; #[cfg(feature = "test-fixtures")] -use crate::realtime::{CommitReceipt, InterimEpoch, ItemResult, Session, SessionRegistry}; +use crate::realtime::{CommitReceipt, ItemResult, Session, SessionRegistry}; #[cfg(feature = "test-fixtures")] mod generation; @@ -90,11 +90,6 @@ impl RealtimeSessionRegistryFixture { } } -/// An opaque interim epoch used by the Realtime session fixture. -#[cfg(feature = "test-fixtures")] -#[derive(Clone, Copy, Debug)] -pub struct RealtimeInterimEpoch(InterimEpoch); - /// The immutable first-append configuration captured by a fixture session. #[cfg(feature = "test-fixtures")] #[derive(Clone, Debug, Eq, PartialEq)] @@ -328,44 +323,47 @@ impl RealtimeSessionFixture { .map_err(|error| error.to_string()) } - /// Begins an interim epoch without spawning work. - /// - /// # Errors - /// Returns the session ownership or epoch error. - pub fn begin_interim(&mut self) -> Result { - self.session - .begin_interim() - .map(RealtimeInterimEpoch) - .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 + pub fn spawn_interim(&mut self, task: F) -> Result<(), String> where F: Future + Send + 'static, { self.session .spawn_interim(task) - .map(RealtimeInterimEpoch) + .map(|_| ()) .map_err(|error| error.to_string()) } - /// Accepts a result and allocates its event ID only after epoch validation. + /// Accepts one interim, clears its input, then rejects the stale epoch. /// /// # Errors - /// Returns a serialization error if the accepted server event cannot serialize. - pub fn accept_interim( + /// Returns an ownership, cleanup, epoch, or serialization error. + pub fn accept_interim_across_clear( &mut self, - epoch: RealtimeInterimEpoch, - transcript: String, - ) -> Result, serde_json::Error> { - self.session - .accept_interim(epoch.0, transcript) + 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. diff --git a/crates/gateway-stt/tests/it/generation.rs b/crates/gateway-stt/tests/it/generation.rs index 1f33b07a..eb464c7f 100644 --- a/crates/gateway-stt/tests/it/generation.rs +++ b/crates/gateway-stt/tests/it/generation.rs @@ -172,67 +172,71 @@ fn replacement_is_serial_and_publishes_one_complete_snapshot() { #[tokio::test] async fn active_replacement_drains_request_and_job_before_unload_and_publication() { let old = ScriptedDecoder::new(); - old.park_next(); let service = service(&old); - let request_service = service.clone(); - let request = tokio::spawn(async move { - transcribe_batch(request_service, "scripted-interim", &[0.25; 16]).await - }); - let parked = old.clone(); - assert!( - tokio::task::spawn_blocking(move || parked.wait_until_parked(WAIT)) - .await - .expect("park observer joins"), - "the old generation owns one running native-equivalent job" - ); - assert_eq!(generation_counts(&service), Some((1, 1))); - 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_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) + 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("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" - ); - - old.release(); + .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") @@ -380,55 +384,55 @@ fn rollback_attempts_reconstruction_after_staged_worker_shutdown_fails() { #[tokio::test] async fn canceled_request_keeps_its_worker_job_owned_until_decode_returns() { let old = ScriptedDecoder::new(); - old.park_next(); let service = service(&old); - let request_service = service.clone(); - let request = tokio::spawn(async move { - transcribe_batch(request_service, "scripted-interim", &[0.25; 16]).await - }); - let parked = old.clone(); - assert!( - tokio::task::spawn_blocking(move || parked.wait_until_parked(WAIT)) + 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("park observer joins"), - "decode reaches the native-equivalent rendezvous" - ); - assert_eq!(generation_counts(&service), Some((1, 1))); - - request.abort(); - assert!( - request + .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_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 = ScriptedDecoder::new(); - 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), - ) - }) + .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("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()); - - old.release(); + .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; diff --git a/crates/gateway-stt/tests/it/realtime_session.rs b/crates/gateway-stt/tests/it/realtime_session.rs index 8294905e..07dcebc9 100644 --- a/crates/gateway-stt/tests/it/realtime_session.rs +++ b/crates/gateway-stt/tests/it/realtime_session.rs @@ -472,21 +472,12 @@ fn stale_interim_is_rejected_before_event_id_allocation() { session .append_base64(&encoded(&[0, 0])) .expect("input appends"); - let stale = session.begin_interim().expect("epoch begins"); - let current_event = session - .accept_interim(stale, "current".to_owned()) - .expect("event serializes") - .expect("current epoch is accepted"); + 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_eq!(session.allocated_event_count(), 1); - - session.clear().expect("input clears"); - assert!( - session - .accept_interim(stale, "stale".to_owned()) - .expect("rejection does not serialize") - .is_none() - ); + assert!(stale_event.is_none()); assert_eq!( session.allocated_event_count(), 1, @@ -571,7 +562,6 @@ fn committed_capacity_is_reserved_before_input_detach_and_retryable() { async fn four_items_finalize_in_reverse_order_without_crossing_ownership() { let interim = ScriptedDecoder::new(); let final_decoder = ScriptedDecoder::new(); - final_decoder.park_next(); for index in 0..COMMITTED_ITEM_CAPACITY { final_decoder.push_text(format!("result-{index}")); } @@ -582,24 +572,29 @@ async fn four_items_finalize_in_reverse_order_without_crossing_ownership() { ) .expect("scripted session starts"); let mut ids = Vec::new(); - 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()); - } - assert_eq!( - session.finalizing_count(), - COMMITTED_ITEM_CAPACITY, - "every committed take owns an asynchronous finalization" - ); - tokio::task::yield_now().await; - assert!( - final_decoder.wait_until_parked(Duration::from_secs(1)), - "one accurate final decode parks while all item tasks remain owned" - ); - final_decoder.release(); + 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!( @@ -640,31 +635,34 @@ async fn canceling_item_finish_keeps_finalization_owned_for_retry() { let interim = ScriptedDecoder::new(); let final_decoder = ScriptedDecoder::new(); final_decoder.push_text("authoritative"); - final_decoder.park_next(); let registry = RealtimeSessionRegistryFixture::default(); let mut session = registry .register_with_scripted_engine( ScriptedModelFactory::new(interim).with_final(final_decoder.clone()), ) .expect("scripted session starts"); - append_decodable(&mut session); - let item = session.commit().expect("item commits"); - let item_id = item.item_id().to_owned(); - - tokio::task::yield_now().await; - assert!( - final_decoder.wait_until_parked(Duration::from_secs(1)), - "the accurate decode remains parked" - ); - 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); - final_decoder.release(); + 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 @@ -773,7 +771,6 @@ async fn asynchronous_final_failure_is_observed_before_the_next_append_and_at_co let interim = ScriptedDecoder::new(); let final_decoder = ScriptedDecoder::new(); final_decoder.push_error("late accurate failure"); - final_decoder.park_next(); let registry = RealtimeSessionRegistryFixture::default(); let mut session = registry .register_with_scripted_engine( @@ -781,15 +778,18 @@ async fn asynchronous_final_failure_is_observed_before_the_next_append_and_at_co ) .expect("scripted session starts"); - session - .append_base64(&closed_segment()) - .expect("closed segment enters the production take"); - tokio::task::yield_now().await; - assert!( - final_decoder.wait_until_parked(Duration::from_secs(1)), - "the accurate segment is running between appends" - ); - final_decoder.release(); + 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() @@ -816,7 +816,6 @@ async fn asynchronous_final_failure_is_observed_before_the_next_append_and_at_co async fn production_final_segment_admission_is_exact_and_fails_atomically() { let interim = ScriptedDecoder::new(); let final_decoder = ScriptedDecoder::new(); - final_decoder.park_next(); final_decoder.push_text("first"); let registry = RealtimeSessionRegistryFixture::default(); let mut session = registry @@ -825,40 +824,45 @@ async fn production_final_segment_admission_is_exact_and_fails_atomically() { ) .expect("scripted session starts"); - session - .append_base64(&closed_segment()) - .expect("first closed segment is admitted"); - tokio::task::yield_now().await; - assert!( - final_decoder.wait_until_parked(Duration::from_secs(1)), - "the first production segment parks in final decoding" - ); - 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()); - } + 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"); - final_decoder.release(); + 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] diff --git a/crates/gateway/src/lib.rs b/crates/gateway/src/lib.rs index 907b29c4..3cd5ed37 100644 --- a/crates/gateway/src/lib.rs +++ b/crates/gateway/src/lib.rs @@ -3625,24 +3625,24 @@ mod provisioning_tests { let service = scripted_service(ScriptedModelFactory::new(old.clone()), 15, 500) .expect("old speech starts"); let next = ScriptedDecoder::new(); - next.park_construction(); let replacement_service = service.clone(); let next_factory = ScriptedModelFactory::new(next.clone()); - let (result_tx, result_rx) = std::sync::mpsc::channel(); - let constructor = std::thread::spawn(move || { - let result = begin_scripted_replacement( - &replacement_service, - next_factory, - false, - Duration::from_millis(20), - ); - drop(result_tx.send(result)); - }); - assert!(next.wait_until_construction_parked(Duration::from_secs(1))); - let error = result_rx - .recv_timeout(Duration::from_secs(1)) - .expect("startup returns at the shared deadline") - .expect_err("parked native-equivalent startup times out"); + 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::RuntimeStageFailure::Fatal(error) = crate::classify_speech_stage_failure(error) else { panic!("non-preemptible speech timeout must be fatal"); @@ -3660,8 +3660,6 @@ mod provisioning_tests { "the old generation was joined before startup" ); assert!(!state.speech.status().ready()); - next.release_construction(); - constructor.join().expect("constructor thread joins"); assert!( next.wait_until_worker_dropped(Duration::from_secs(1)), "abandoned startup worker exits after construction returns" diff --git a/crates/gateway/tests/it/realtime_stt.rs b/crates/gateway/tests/it/realtime_stt.rs index f446aa97..9da77caa 100644 --- a/crates/gateway/tests/it/realtime_stt.rs +++ b/crates/gateway/tests/it/realtime_stt.rs @@ -345,7 +345,6 @@ async fn assert_stop_reconciles_skipped_range(short_input_samples: usize) { interim.push_text("last word"); let final_decoder = ScriptedDecoder::new(); final_decoder.push_text("corrected first"); - final_decoder.park_next(); 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; @@ -369,34 +368,37 @@ async fn assert_stop_reconciles_skipped_range(short_input_samples: usize) { vec![8_192; short_input_samples], ] .concat(); - append_audio(&mut socket, audio_samples(&before_stop)).await; - let hypothesis = expect_type( - &mut socket, - "conversation.item.input_audio_transcription.hypothesis", - ) - .await; - assert!( - hypothesis["transcript"] - .as_str() - .is_some_and(|text| text.ends_with("last word")) - ); - let parked = final_decoder.clone(); - assert!( - tokio::task::spawn_blocking(move || parked.wait_until_parked(PHASE_TIMEOUT)) - .await - .expect("finalization park observer joins"), - "the accepted hypothesis is captured while earlier final work is parked" - ); - - append_audio(&mut socket, audio_samples(&vec![0; 72_000])).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; - final_decoder.release(); + 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" { @@ -430,7 +432,6 @@ async fn assert_same_range_final_authority(final_text: &str, expected: &str) { interim.push_text("provisional words"); let final_decoder = ScriptedDecoder::new(); final_decoder.push_text(final_text); - final_decoder.park_next(); 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; @@ -448,28 +449,29 @@ async fn assert_same_range_final_authority(final_text: &str, expected: &str) { .await; expect_type(&mut socket, "session.updated").await; - 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; - let parked = final_decoder.clone(); - assert!( - tokio::task::spawn_blocking(move || parked.wait_until_parked(PHASE_TIMEOUT)) - .await - .expect("finalization park observer joins"), - "the exact accepted range reaches authoritative final decoding" - ); - final_decoder.release(); + 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", @@ -555,115 +557,11 @@ async fn assert_final_speech_route_surface(http: &reqwest::Client, address: Sock } } -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"); -} - 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/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 index bf7350bb..77e8eb2b 100644 --- a/crates/gateway/tests/it/realtime_stt/lifecycle.rs +++ b/crates/gateway/tests/it/realtime_stt/lifecycle.rs @@ -1,90 +1,4 @@ #[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.park_next(); - append_audio(&mut socket, audio()).await; - let parked = interim.clone(); - assert!( - tokio::task::spawn_blocking(move || parked.wait_until_parked(PHASE_TIMEOUT)) - .await - .expect("park observer joins"), - "the first eligible scheduled decode parks" - ); - for _ in 0..5 { - append_audio(&mut 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" - ); - - interim.release(); - 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.park_next(); - for _ in 0..5 { - append_audio(&mut socket, audio()).await; - } - let canceled = interim.clone(); - assert!( - tokio::task::spawn_blocking(move || canceled.wait_until_parked(PHASE_TIMEOUT)) - .await - .expect("cancellation park observer joins") - ); - send( - &mut socket, - serde_json::json!({"type": "input_audio_buffer.clear"}), - ) - .await; - expect_type(&mut socket, "input_audio_buffer.cleared").await; - interim.release(); - 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; -} -#[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); @@ -93,31 +7,37 @@ async fn completion_cadence_reaps_more_than_eight_canceled_interims() { expect_type(&mut socket, "session.created").await; for request_count in 1..=10 { - interim.park_next(); - for _ in 0..5 { - append_audio(&mut socket, audio()).await; - } - let parked = interim.clone(); - assert!( - tokio::task::spawn_blocking(move || { - parked.wait_for_requests(request_count, PHASE_TIMEOUT) - && parked.wait_until_parked(PHASE_TIMEOUT) - }) + 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 - .expect("park observer joins"), - "scheduled interim {request_count} reaches its worker" - ); - send( - &mut socket, - serde_json::json!({"type": "input_audio_buffer.clear"}), - ) - .await; - assert_eq!( - expect_type(&mut socket, "input_audio_buffer.cleared").await["type"], - "input_audio_buffer.cleared", - "completed canceled joins free bounded capacity before cycle {request_count}" - ); - interim.release(); + .unwrap_or_else(|| { + panic!("scheduled interim {request_count} reaches the blocked scenario") + }); let completed = interim.clone(); assert!( tokio::task::spawn_blocking(move || { @@ -169,27 +89,29 @@ async fn consumed_boundary_rebases_before_delayed_finalization_completes() { .await; assert_eq!(first["transcript"], "first phrase"); - final_decoder.park_next(); - append_audio( - &mut socket, - audio_samples(&[vec![0; 72_000], vec![8_192; 12_000]].concat()), - ) - .await; - let parked = final_decoder.clone(); - assert!( - tokio::task::spawn_blocking(move || parked.wait_until_parked(PHASE_TIMEOUT)) - .await - .expect("finalization park observer joins") - ); - let pending = expect_type( - &mut socket, - "conversation.item.input_audio_transcription.hypothesis", - ) - .await; - assert_eq!(pending["transcript"], "first phrase second phrase"); - assert_eq!(pending["finalized"], ""); - - final_decoder.release(); + 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)) diff --git a/crates/gateway/tests/it/realtime_stt/overload.rs b/crates/gateway/tests/it/realtime_stt/overload.rs index 6f3a1558..d60a8040 100644 --- a/crates/gateway/tests/it/realtime_stt/overload.rs +++ b/crates/gateway/tests/it/realtime_stt/overload.rs @@ -63,100 +63,3 @@ async fn mounted_terminal_failures_preserve_their_typed_wire_reason() { server.shutdown().await; } } -#[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(); - final_decoder.park_next(); - 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 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); - } - let parked = final_decoder.clone(); - assert!( - tokio::task::spawn_blocking(move || parked.wait_until_parked(PHASE_TIMEOUT)) - .await - .expect("park observer joins"), - "four committed items remain outstanding behind the parked final worker" - ); - assert_eq!( - final_decoder.requests().len(), - 1, - "the serial final worker is parked while four items own finalization" - ); - - for _ in 0..5 { - send(&mut socket, append.clone()).await; - } - send(&mut socket, commit).await; - let saturated = expect_type(&mut socket, "error").await; - let requests_at_saturation = final_decoder.requests().len(); - final_decoder.release(); - 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/recovery.rs b/crates/gateway/tests/it/realtime_stt/recovery.rs index 9b22a0c9..8b4793ef 100644 --- a/crates/gateway/tests/it/realtime_stt/recovery.rs +++ b/crates/gateway/tests/it/realtime_stt/recovery.rs @@ -2,7 +2,6 @@ async fn admission_is_bounded_and_replacement_closes_with_1012() { let interim = ScriptedDecoder::new(); let final_decoder = ScriptedDecoder::new(); - final_decoder.park_next(); final_decoder.push_text("too late"); let service = speech(&interim, Some(&final_decoder)); let server = server(true, &service).await; @@ -12,16 +11,8 @@ async fn admission_is_bounded_and_replacement_closes_with_1012() { expect_type(&mut socket, "session.created").await; sockets.push(socket); } - assert_eq!( - rejected( - server.addr, - "intent=transcription", - Some("test-token"), - None - ) - .await, - 429 - ); + 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"); } @@ -35,53 +26,58 @@ async fn admission_is_bounded_and_replacement_closes_with_1012() { ) .await; } - 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; - let parked = final_decoder.clone(); - assert!( - tokio::task::spawn_blocking(move || parked.wait_until_parked(PHASE_TIMEOUT)) - .await - .expect("park observer joins"), - "committed item owns its final decode" - ); - let replacement = ScriptedDecoder::new(); let replacement_final = ScriptedDecoder::new(); - let replacement_service = service.clone(); - let replacement_task = tokio::task::spawn_blocking(move || { - begin_scripted_replacement( - &replacement_service, - ScriptedModelFactory::new(replacement).with_final(replacement_final), - true, + 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,) + }, ) - }); - 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"); - drop(sockets); - final_decoder.release(); - + .expect("the committed item owns one blocked final decode"); + let (replacement_task,) = replacement_task; let staged = replacement_task .await .expect("replacement task joins") 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/tools/integration-test-ceilings.json b/tools/integration-test-ceilings.json index 1344663d..b7bf8171 100644 --- a/tools/integration-test-ceilings.json +++ b/tools/integration-test-ceilings.json @@ -5,15 +5,17 @@ "testTotal": 19, "entry": { "path": "crates/gateway/tests/it/realtime_stt.rs", - "ceiling": 669 + "ceiling": 567 }, "files": { "authentication.rs": 89, "canonical_sequence.rs": 102, - "lifecycle.rs": 227, - "overload.rs": 162, - "protocol.rs": 377, - "recovery.rs": 151 + "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": { diff --git a/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index f154b954..2b4ed5c8 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -366,7 +366,7 @@ isProject: false - 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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index e042f665..dc2a17d9 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -146,8 +146,8 @@ N17 | observation | Violates A2 @ crates/gateway-stt/tests/it/architecture.rs: n 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 -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 +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 @@ -195,3 +195,4 @@ N66 | observation | shared-parameter-cluster @ crates/gateway-logging/src/queue. 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 From 8a56edd3b365afcf9a97097c9ad372140431f62a Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 14:09:04 -0700 Subject: [PATCH 68/86] Restore dead-code diagnostics for gateway STT Restore dead-code diagnostics across default, feature-enabled, unit-test, Miri, and featureless builds. Assign configuration-specific state to its build owner, enforce the policy recursively, and deny production warnings in continuous integration. - `#[cfg(any(test, feature = "test-fixtures"))]` assigns fixture-visible fields, variants, imports, and inspection methods to test builds. `#[cfg(test)]` limits unit-test helpers, and `take` keeps one reasoned item-level allowance for retirement ownership. - `requireNoBroadDeadCodeAllowances` recursively scans all Rust sources under the speech crate. `maskRustCommentsAndLiterals` excludes inert text before `broadDeadCodeAllowances` rejects crate and module suppressions. - `crates/gateway-stt/src/lib.rs` removes the module-wide allowances from `audio` and `realtime`. - `cargo check --locked -p gateway-stt --lib` runs with `RUSTFLAGS` set to deny warnings before the existing architecture test. - `module-ceilings.toml` banks current physical line counts for each touched speech module. Design: new shotgun-surgery @ crates/gateway-stt/src Design: new oversized-unit @ tools/check-stt-architecture.mjs::maskRustCommentsAndLiterals deps: source Violates: A2 - credential ownership in crates/gateway-stt/src/realtime is not determinable from diff Pending: N30 - compounds Plan: vibe/2026-09-07-1-promptforge-debt.md --- .github/workflows/ci.yml | 5 + crates/gateway-stt/module-ceilings.toml | 34 +-- crates/gateway-stt/src/audio.rs | 3 + crates/gateway-stt/src/generation.rs | 9 +- crates/gateway-stt/src/lib.rs | 2 - crates/gateway-stt/src/realtime/input.rs | 10 +- crates/gateway-stt/src/realtime/item.rs | 13 +- crates/gateway-stt/src/realtime/mod.rs | 1 + crates/gateway-stt/src/realtime/registry.rs | 4 + .../src/realtime/result_mailbox.rs | 25 +- crates/gateway-stt/src/realtime/route.rs | 20 +- crates/gateway-stt/src/realtime/session.rs | 11 +- .../gateway-stt/src/realtime/session/items.rs | 7 + .../gateway-stt/src/realtime/session/route.rs | 22 +- .../gateway-stt/src/realtime/session/state.rs | 22 +- .../gateway-stt/src/realtime/wire/server.rs | 10 + .../src/realtime/wire/server/events.rs | 4 +- .../gateway-stt/src/realtime/wire/shared.rs | 3 + crates/gateway-stt/src/replacement.rs | 32 +-- crates/gateway-stt/src/segment.rs | 1 + crates/gateway-stt/src/take.rs | 2 + tools/check-stt-architecture.mjs | 221 +++++++++++++++++- tools/check-stt-architecture.test.mjs | 42 +++- vibe/2026-09-07-1-promptforge-debt.md | 2 +- vibe/archdoc-next.md | 3 +- 25 files changed, 423 insertions(+), 85 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55c9df17..5381ed83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,6 +73,11 @@ jobs: - 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 diff --git a/crates/gateway-stt/module-ceilings.toml b/crates/gateway-stt/module-ceilings.toml index dad48f9d..c0be4c38 100644 --- a/crates/gateway-stt/module-ceilings.toml +++ b/crates/gateway-stt/module-ceilings.toml @@ -7,37 +7,37 @@ test_fixture_public_root_count = 7 [modules] "artifacts.rs" = 346 -"audio.rs" = 397 +"audio.rs" = 400 "batch.rs" = 347 "batch/native_tests.rs" = 113 "batch/tests.rs" = 160 -"generation.rs" = 447 +"generation.rs" = 452 "generation/lease.rs" = 130 "generation/snapshot.rs" = 158 -"lib.rs" = 39 +"lib.rs" = 37 "model.rs" = 105 -"realtime/mod.rs" = 16 -"realtime/input.rs" = 195 -"realtime/item.rs" = 170 +"realtime/mod.rs" = 17 +"realtime/input.rs" = 201 +"realtime/item.rs" = 181 "realtime/query.rs" = 70 -"realtime/registry.rs" = 234 -"realtime/result_mailbox.rs" = 232 -"realtime/route.rs" = 440 -"realtime/session.rs" = 481 -"realtime/session/items.rs" = 155 +"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" = 109 +"realtime/session/state.rs" = 113 "realtime/wire.rs" = 24 "realtime/wire/client.rs" = 363 -"realtime/wire/server.rs" = 390 +"realtime/wire/server.rs" = 400 "realtime/wire/server/events.rs" = 180 -"realtime/wire/shared.rs" = 255 +"realtime/wire/shared.rs" = 258 "realtime/wire/tests.rs" = 278 -"replacement.rs" = 473 -"segment.rs" = 253 +"replacement.rs" = 479 +"segment.rs" = 254 "service.rs" = 126 "status.rs" = 54 -"take.rs" = 272 +"take.rs" = 274 "take/agreement.rs" = 30 "take/final_outcome.rs" = 98 "take/finalization.rs" = 379 diff --git a/crates/gateway-stt/src/audio.rs b/crates/gateway-stt/src/audio.rs index abf5b283..99c3cc75 100644 --- a/crates/gateway-stt/src/audio.rs +++ b/crates/gateway-stt/src/audio.rs @@ -36,6 +36,7 @@ impl CommittedAudio { &self.samples } + #[cfg(test)] pub(super) const fn input_samples(&self) -> usize { self.input_samples } @@ -90,6 +91,7 @@ impl AudioBuffer { Ok(()) } + #[cfg(test)] pub(super) fn commit(&mut self) -> Result { self.validate_commit()?; Ok(self.commit_validated()) @@ -126,6 +128,7 @@ impl AudioBuffer { 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 diff --git a/crates/gateway-stt/src/generation.rs b/crates/gateway-stt/src/generation.rs index 89f7edfe..8294a4a3 100644 --- a/crates/gateway-stt/src/generation.rs +++ b/crates/gateway-stt/src/generation.rs @@ -5,10 +5,14 @@ use std::sync::{Arc, PoisonError, RwLock, Weak}; use std::time::{Duration, Instant}; use gateway_stt_backend_whisper::{WhisperConfig, WhisperModelFactory}; -use gateway_stt_engine::{DecodeMode, EnginePolicy, ModelFactory}; +#[cfg(feature = "test-fixtures")] +use gateway_stt_engine::ModelFactory; +use gateway_stt_engine::{DecodeMode, EnginePolicy}; use crate::artifacts::{PreparedSpeech, SpeechError}; -use crate::model::{ModelNames, SpeechModelInfo}; +#[cfg(feature = "test-fixtures")] +use crate::model::ModelNames; +use crate::model::SpeechModelInfo; use crate::replacement::{DrainOutcome, ReplacementCoordinator, ReplacementPermit}; use crate::status::SpeechStatus; @@ -274,6 +278,7 @@ impl GenerationState { .map(|generation| generation.admission.counts()) } + #[cfg(feature = "test-fixtures")] fn replace_with( &self, timeout: Duration, diff --git a/crates/gateway-stt/src/lib.rs b/crates/gateway-stt/src/lib.rs index f050c047..03d58e47 100644 --- a/crates/gateway-stt/src/lib.rs +++ b/crates/gateway-stt/src/lib.rs @@ -4,12 +4,10 @@ //! publication, batch transcription, and Realtime transcription. mod artifacts; -#[allow(dead_code)] mod audio; mod batch; mod generation; mod model; -#[allow(dead_code)] mod realtime; mod replacement; mod segment; diff --git a/crates/gateway-stt/src/realtime/input.rs b/crates/gateway-stt/src/realtime/input.rs index 3cf0aaf1..911d702e 100644 --- a/crates/gateway-stt/src/realtime/input.rs +++ b/crates/gateway-stt/src/realtime/input.rs @@ -1,7 +1,6 @@ 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"; @@ -26,18 +25,22 @@ impl InputSnapshot { } } + #[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 } @@ -58,12 +61,13 @@ pub(crate) struct UncommittedInput { #[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, @@ -124,6 +128,7 @@ impl UncommittedInput { &self.take } + #[cfg(test)] pub(crate) fn buffered_duration_seconds(&self) -> f64 { self.audio.buffered_duration_seconds() } @@ -145,6 +150,7 @@ impl UncommittedInput { 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(), diff --git a/crates/gateway-stt/src/realtime/item.rs b/crates/gateway-stt/src/realtime/item.rs index 5ef331ba..1be171f5 100644 --- a/crates/gateway-stt/src/realtime/item.rs +++ b/crates/gateway-stt/src/realtime/item.rs @@ -2,7 +2,9 @@ use std::sync::Arc; use tokio::task::JoinHandle; -use super::input::{InputSnapshot, SealedInput}; +#[cfg(any(test, feature = "test-fixtures"))] +use super::input::InputSnapshot; +use super::input::SealedInput; use super::result_mailbox::{ItemFailure, ItemResult}; use crate::take::Take; @@ -35,7 +37,12 @@ impl CommitReceipt { 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, @@ -58,6 +65,7 @@ impl CommittedItem { Self { id: sealed.item_id, previous_item_id, + #[cfg(any(test, feature = "test-fixtures"))] snapshot: sealed.snapshot, take, duration_seconds: sealed.duration_seconds, @@ -76,14 +84,17 @@ impl CommittedItem { &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() } diff --git a/crates/gateway-stt/src/realtime/mod.rs b/crates/gateway-stt/src/realtime/mod.rs index 951fab8e..0a1cfaa2 100644 --- a/crates/gateway-stt/src/realtime/mod.rs +++ b/crates/gateway-stt/src/realtime/mod.rs @@ -9,6 +9,7 @@ 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; diff --git a/crates/gateway-stt/src/realtime/registry.rs b/crates/gateway-stt/src/realtime/registry.rs index cdc957b3..38f93413 100644 --- a/crates/gateway-stt/src/realtime/registry.rs +++ b/crates/gateway-stt/src/realtime/registry.rs @@ -1,3 +1,4 @@ +#[cfg(feature = "test-fixtures")] use std::future::Future; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; @@ -31,10 +32,12 @@ impl CleanupSignal { 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 { @@ -131,6 +134,7 @@ impl SessionRegistry { }) } + #[cfg(any(test, feature = "test-fixtures"))] pub(crate) fn active(&self) -> usize { self.shared .state diff --git a/crates/gateway-stt/src/realtime/result_mailbox.rs b/crates/gateway-stt/src/realtime/result_mailbox.rs index 093f0143..1e9b7273 100644 --- a/crates/gateway-stt/src/realtime/result_mailbox.rs +++ b/crates/gateway-stt/src/realtime/result_mailbox.rs @@ -18,6 +18,7 @@ impl ItemFailure { } } + #[cfg(feature = "test-fixtures")] pub(crate) fn diagnostic(&self) -> &str { match self { Self::FinalSegmentOverload(message) @@ -29,10 +30,9 @@ impl ItemFailure { #[derive(Clone, Debug, PartialEq)] pub(crate) enum ItemResult { - Delta { - item_id: String, - transcript: String, - }, + #[cfg(any(test, feature = "test-fixtures"))] + Delta { item_id: String, transcript: String }, + #[cfg(any(test, feature = "test-fixtures"))] Hypothesis { item_id: String, revision: u64, @@ -52,10 +52,9 @@ pub(crate) enum ItemResult { impl ItemResult { pub(crate) fn item_id(&self) -> &str { match self { - Self::Delta { item_id, .. } - | Self::Hypothesis { item_id, .. } - | Self::Completed { item_id, .. } - | Self::Failed { item_id, .. } => item_id, + #[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, } } @@ -66,12 +65,14 @@ impl ItemResult { #[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")] @@ -82,8 +83,10 @@ pub(crate) enum MailboxError { #[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, } @@ -94,6 +97,7 @@ impl ResultMailbox { 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, @@ -113,6 +117,7 @@ impl ResultMailbox { Ok(()) } + #[cfg(any(test, feature = "test-fixtures"))] pub(crate) fn replace_hypothesis( &mut self, item_id: &str, @@ -157,7 +162,11 @@ impl ResultMailbox { } 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 diff --git a/crates/gateway-stt/src/realtime/route.rs b/crates/gateway-stt/src/realtime/route.rs index 7a877f18..d6784ba6 100644 --- a/crates/gateway-stt/src/realtime/route.rs +++ b/crates/gateway-stt/src/realtime/route.rs @@ -301,7 +301,6 @@ fn event_id(event: &ClientEvent) -> Option { | 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( @@ -340,13 +339,15 @@ fn session_error(error: &SessionError, client_event_id: Option) -> Clien None, client_event_id, ), - SessionError::InterimAtCapacity | SessionError::Mailbox(MailboxError::ResultAtCapacity) => { - ClientError::overload( - "result_queue_overload", - "The session result queue is full", - 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", @@ -381,7 +382,6 @@ fn session_error(error: &SessionError, client_event_id: Option) -> Clien } } } - async fn send_events(socket: &mut WebSocket, events: &[ServerEvent], policy: &RoutePolicy) -> bool { for event in events { if !send_event(socket, event, policy).await { @@ -390,7 +390,6 @@ async fn send_events(socket: &mut WebSocket, events: &[ServerEvent], policy: &Ro } true } - async fn send_client_error( socket: &mut WebSocket, session: &Session, @@ -404,7 +403,6 @@ async fn send_client_error( ) .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, diff --git a/crates/gateway-stt/src/realtime/session.rs b/crates/gateway-stt/src/realtime/session.rs index 4acffc45..7a3dbfd5 100644 --- a/crates/gateway-stt/src/realtime/session.rs +++ b/crates/gateway-stt/src/realtime/session.rs @@ -1,11 +1,10 @@ -use std::future::Future; - 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; @@ -49,6 +48,7 @@ impl Session { Ok(()) } + #[cfg(any(test, feature = "test-fixtures"))] pub(crate) const fn input(&self) -> Option<&UncommittedInput> { self.input.as_ref() } @@ -87,6 +87,7 @@ impl Session { Ok(epoch) } + #[cfg(any(test, feature = "test-fixtures"))] pub(crate) fn spawn_interim(&mut self, task: F) -> Result where F: Future + Send + 'static, @@ -106,6 +107,7 @@ impl Session { Ok(epoch) } + #[cfg(any(test, feature = "test-fixtures"))] pub(crate) fn accept_interim( &mut self, epoch: InterimEpoch, @@ -129,6 +131,7 @@ impl Session { 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)) } @@ -142,6 +145,7 @@ impl Session { .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() } @@ -171,6 +175,7 @@ impl Session { 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; diff --git a/crates/gateway-stt/src/realtime/session/items.rs b/crates/gateway-stt/src/realtime/session/items.rs index 65ca5066..53feea03 100644 --- a/crates/gateway-stt/src/realtime/session/items.rs +++ b/crates/gateway-stt/src/realtime/session/items.rs @@ -45,10 +45,12 @@ impl Session { 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() @@ -73,12 +75,14 @@ impl Session { 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, @@ -88,6 +92,7 @@ impl Session { Ok(()) } + #[cfg(any(test, feature = "test-fixtures"))] pub(crate) fn replace_hypothesis( &mut self, item_id: &str, @@ -99,6 +104,7 @@ impl Session { Ok(()) } + #[cfg(any(test, feature = "test-fixtures"))] pub(crate) fn finalize_completed( &mut self, item_id: &str, @@ -115,6 +121,7 @@ impl Session { Ok(()) } + #[cfg(any(test, feature = "test-fixtures"))] pub(crate) fn finalize_failed( &mut self, item_id: &str, diff --git a/crates/gateway-stt/src/realtime/session/route.rs b/crates/gateway-stt/src/realtime/session/route.rs index 643f1a5c..e51e915c 100644 --- a/crates/gateway-stt/src/realtime/session/route.rs +++ b/crates/gateway-stt/src/realtime/session/route.rs @@ -1,10 +1,8 @@ -use gateway_stt_engine::{DecodeMode, DecodeRequest, EnginePolicy}; - 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()) @@ -13,11 +11,9 @@ impl Session { 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() } @@ -32,7 +28,6 @@ impl Session { } Ok(()) } - pub(crate) fn schedule_interim(&mut self) -> Result<(), SessionError> { if self.interim_task.is_some() { return Ok(()); @@ -81,11 +76,11 @@ impl Session { })); 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, @@ -97,6 +92,15 @@ impl Session { 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); } @@ -152,7 +156,6 @@ impl Session { }) .collect() } - pub(crate) fn committed_events( &self, receipt: &crate::realtime::CommitReceipt, @@ -164,14 +167,12 @@ impl Session { 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 @@ -184,7 +185,6 @@ impl Session { } Ok(self.drain_events()) } - pub(crate) fn replacement_events(&self) -> Vec { let mut events = self .committed diff --git a/crates/gateway-stt/src/realtime/session/state.rs b/crates/gateway-stt/src/realtime/session/state.rs index 97866f9d..47d58d59 100644 --- a/crates/gateway-stt/src/realtime/session/state.rs +++ b/crates/gateway-stt/src/realtime/session/state.rs @@ -1,7 +1,3 @@ -use std::collections::HashMap; - -use tokio::task::JoinHandle; - use crate::audio::AudioError; use crate::generation::GenerationLease; use crate::realtime::input::UncommittedInput; @@ -9,16 +5,16 @@ 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, @@ -29,7 +25,6 @@ pub(super) enum InterimTaskOutput { transcript: Result, }, } - #[derive(Debug, Eq, PartialEq, thiserror::Error)] pub(crate) enum SessionError { #[error(transparent)] @@ -55,9 +50,18 @@ pub(crate) enum SessionError { #[error("{0}")] Finalization(String), #[error(transparent)] - Mailbox(#[from] MailboxError), + 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, diff --git a/crates/gateway-stt/src/realtime/wire/server.rs b/crates/gateway-stt/src/realtime/wire/server.rs index 46704f7c..4ee905b3 100644 --- a/crates/gateway-stt/src/realtime/wire/server.rs +++ b/crates/gateway-stt/src/realtime/wire/server.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +#[cfg(test)] use serde_json::Value; use super::client::parse_client_event; @@ -104,6 +105,7 @@ impl EffectiveSession { !self.include.is_empty() } + #[cfg(test)] fn validate(&self) -> Result<(), String> { if self.id.is_empty() || self.object != SESSION_OBJECT @@ -241,12 +243,14 @@ impl ServerEvent { } } + #[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 } @@ -335,6 +339,7 @@ impl ServerEvent { } impl ConversationItem { + #[cfg(test)] fn validate(&self) -> Result<(), String> { validate_id(&self.id)?; if self.r#type != "message" @@ -351,6 +356,7 @@ impl ConversationItem { } 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()); @@ -360,10 +366,12 @@ impl DurationUsage { } 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() @@ -377,6 +385,7 @@ impl WireError { } } +#[cfg(test)] fn validate_id(id: &str) -> Result<(), String> { if id.is_empty() { Err("opaque ID must not be empty".to_owned()) @@ -385,6 +394,7 @@ fn validate_id(id: &str) -> Result<(), String> { } } +#[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 index 1825045b..ae01bd66 100644 --- a/crates/gateway-stt/src/realtime/wire/server/events.rs +++ b/crates/gateway-stt/src/realtime/wire/server/events.rs @@ -4,7 +4,6 @@ use super::{ 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, @@ -79,10 +78,12 @@ impl ServerEvent { 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, @@ -168,7 +169,6 @@ fn item_failure_error(failure: &ItemFailure) -> WireError { event_id: OptionalNullable::Missing, } } - fn replacement_error(event_id: OptionalNullable) -> WireError { WireError { r#type: "server_error".to_owned(), diff --git a/crates/gateway-stt/src/realtime/wire/shared.rs b/crates/gateway-stt/src/realtime/wire/shared.rs index 9ff3dc4a..0c2adbaf 100644 --- a/crates/gateway-stt/src/realtime/wire/shared.rs +++ b/crates/gateway-stt/src/realtime/wire/shared.rs @@ -130,6 +130,7 @@ pub(crate) enum RequiredNullable { } impl RequiredNullable { + #[cfg(test)] pub(super) fn as_ref(&self) -> Option<&T> { match self { Self::Null => None, @@ -137,6 +138,7 @@ impl RequiredNullable { } } + #[cfg(test)] pub(super) fn is_null(&self) -> bool { matches!(self, Self::Null) } @@ -183,6 +185,7 @@ impl OptionalNullable { matches!(self, Self::Missing) } + #[cfg(test)] pub(super) fn invalid_empty(&self) -> bool where T: AsRef, diff --git a/crates/gateway-stt/src/replacement.rs b/crates/gateway-stt/src/replacement.rs index 4c9b452f..86d081e0 100644 --- a/crates/gateway-stt/src/replacement.rs +++ b/crates/gateway-stt/src/replacement.rs @@ -1,29 +1,23 @@ //! 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); @@ -57,13 +51,11 @@ impl ReplacementCoordinator { } } } - /// 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 @@ -72,7 +64,6 @@ impl fmt::Debug for ReplacementPermit { .finish_non_exhaustive() } } - impl ReplacementPermit { pub(crate) fn with_current(&self, operation: impl FnOnce() -> T) -> Option { let identity = self.identity.as_ref()?; @@ -137,6 +128,7 @@ impl Drop for ShutdownPermit { #[derive(Debug)] struct EpochState { + #[cfg(any(test, feature = "test-fixtures"))] id: u64, cancelled: AtomicBool, changed: Notify, @@ -149,9 +141,10 @@ pub(crate) struct SessionEpoch { } impl SessionEpoch { - fn new(id: u64) -> Self { + 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(), @@ -159,6 +152,7 @@ impl SessionEpoch { } } + #[cfg(any(test, feature = "test-fixtures"))] pub(crate) fn id(&self) -> u64 { self.state.id } @@ -199,6 +193,7 @@ struct AdmissionState { requests: usize, jobs: usize, epoch: SessionEpoch, + #[cfg(any(test, feature = "test-fixtures"))] next_epoch: u64, } @@ -216,7 +211,11 @@ impl Default for AdmissionGate { admission: Admission::Open, requests: 0, jobs: 0, - epoch: SessionEpoch::new(1), + epoch: SessionEpoch::new( + #[cfg(any(test, feature = "test-fixtures"))] + 1, + ), + #[cfg(any(test, feature = "test-fixtures"))] next_epoch: 2, }), changed: Condvar::new(), @@ -259,8 +258,14 @@ impl AdmissionGate { return None; } let identity = Arc::new(CloseIdentity); - let next_epoch = SessionEpoch::new(state.next_epoch); - state.next_epoch = state.next_epoch.wrapping_add(1).max(1); + 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 }) @@ -305,6 +310,7 @@ impl AdmissionGate { ) } + #[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) diff --git a/crates/gateway-stt/src/segment.rs b/crates/gateway-stt/src/segment.rs index 6cc35d5e..9a082586 100644 --- a/crates/gateway-stt/src/segment.rs +++ b/crates/gateway-stt/src/segment.rs @@ -53,6 +53,7 @@ pub(crate) struct Segmenter { impl Segmenter { /// A fresh segmenter positioned at the start of a take buffer. #[must_use] + #[cfg(any(test, feature = "test-fixtures"))] pub(crate) fn new() -> Self { Self::default() } diff --git a/crates/gateway-stt/src/take.rs b/crates/gateway-stt/src/take.rs index cd4d80a6..4fcd8897 100644 --- a/crates/gateway-stt/src/take.rs +++ b/crates/gateway-stt/src/take.rs @@ -23,6 +23,7 @@ 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)..] } @@ -82,6 +83,7 @@ impl Take { 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); diff --git a/tools/check-stt-architecture.mjs b/tools/check-stt-architecture.mjs index 6d5a24aa..60b52b38 100644 --- a/tools/check-stt-architecture.mjs +++ b/tools/check-stt-architecture.mjs @@ -1,5 +1,5 @@ import { spawnSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { readFileSync, readdirSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -20,6 +20,222 @@ 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}`) { @@ -358,6 +574,9 @@ function checkNodeVersion() { 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", diff --git a/tools/check-stt-architecture.test.mjs b/tools/check-stt-architecture.test.mjs index b3386518..ae84fb68 100644 --- a/tools/check-stt-architecture.test.mjs +++ b/tools/check-stt-architecture.test.mjs @@ -1,15 +1,20 @@ import assert from "node:assert/strict"; -import { join } from "node:path"; +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, @@ -18,6 +23,41 @@ import { 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 { diff --git a/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index 2b4ed5c8..e438cba0 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -376,7 +376,7 @@ isProject: false - 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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index dc2a17d9..9b9217e5 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -155,7 +155,7 @@ N26 | observation | constructor-injection @ crates/gateway-stt/src/runtime.rs::S 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 +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 @@ -196,3 +196,4 @@ N67 | observation | flag-parameter @ crates/gateway-logging/src/queue.rs::LogQue 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 From ad5a72c3c36585b0770b6c1853a6312c59116607 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 14:35:37 -0700 Subject: [PATCH 69/86] Pin the native STT runner contract Require the native job to validate preinstalled Rust 1.89.0 before it can publish a tool directory or use the cache. Support direct tools without rustup, and resolve rustup only after cargo identifies a proxy layout. - `.github/workflows/stt-miri.yml` discovers `cargo.exe` and `rustc.exe` from `PROMPTFORGE_RUST_1_89_0_BIN` or `PATH`. It sets `RUSTUP_TOOLCHAIN` to `1.89`, keeps `RUSTUP_AUTO_INSTALL` at `"0"`, and requires exact tool version `1.89.0`. - `Test-RustupProxy` probes `cargo` with `+$env:RUSTUP_TOOLCHAIN`. Direct layouts skip `rustup`, while proxy layouts resolve it conditionally and require matching `SHA256` hashes for the selected executables. - `tools/check-stt-native-workflow.test.mjs` executes the extracted preflight against direct contract and `PATH` proxy fixtures. It asserts that version validation finishes before cache use and that all native Whisper work stays on `[self-hosted, windows, cuda]`. - `.github/workflows/stt-miri.yml` adds no Rust installer and leaves the hosted Miri, native fixture source and hash, and native Whisper command pins unchanged. Plan: vibe/2026-09-07-1-promptforge-debt.md --- .github/workflows/stt-miri.yml | 36 ++++- tools/check-stt-native-workflow.test.mjs | 189 ++++++++++++++++++++++- vibe/2026-09-07-1-promptforge-debt.md | 2 +- 3 files changed, 217 insertions(+), 10 deletions(-) diff --git a/.github/workflows/stt-miri.yml b/.github/workflows/stt-miri.yml index 309c7977..4b175b5d 100644 --- a/.github/workflows/stt-miri.yml +++ b/.github/workflows/stt-miri.yml @@ -47,7 +47,7 @@ jobs: runs-on: [self-hosted, windows, cuda] timeout-minutes: 90 env: - RUSTUP_TOOLCHAIN: 1.89.0 + RUSTUP_TOOLCHAIN: 1.89 RUSTUP_AUTO_INSTALL: "0" steps: - uses: actions/checkout@v4 @@ -121,11 +121,45 @@ jobs: 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 $contractBin $rustc = Resolve-RustTool -Name 'rustc' -Bin $contractBin 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 $contractBin + $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' + } + if (-not [string]::IsNullOrWhiteSpace($contractBin)) { $contractBin | Add-Content -Path $env:GITHUB_PATH } diff --git a/tools/check-stt-native-workflow.test.mjs b/tools/check-stt-native-workflow.test.mjs index 14d72c6f..bcdee2db 100644 --- a/tools/check-stt-native-workflow.test.mjs +++ b/tools/check-stt-native-workflow.test.mjs @@ -1,8 +1,17 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { + chmodSync, + copyFileSync, + 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 from "node:test"; +import test, { after, before } from "node:test"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const workflow = readFileSync( @@ -11,6 +20,13 @@ const workflow = readFileSync( ); 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`; @@ -24,6 +40,113 @@ function jobSource(name) { 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, { proxy = false } = {}) { + const bin = mkdtempSync(join(fixtureRoot, proxy ? "proxy-bin-" : "direct-bin-")); + for (const name of names) { + const destination = join(bin, `${name}.exe`); + copyFileSync(fakeTool, destination); + chmodSync(destination, 0o755); + } + return bin; +} + +function runPreflight({ bin, explicitBin }) { + const environment = { ...process.env }; + for (const key of Object.keys(environment)) { + if (key.toUpperCase() === "PROMPTFORGE_RUST_1_89_0_BIN") { + delete environment[key]; + } + } + environment.GITHUB_PATH = join(fixtureRoot, "github-path"); + environment.RUSTUP_TOOLCHAIN = "1.89"; + environment.RUSTUP_AUTO_INSTALL = "0"; + if (explicitBin) { + environment.PROMPTFORGE_RUST_1_89_0_BIN = bin; + } else { + const pathKey = + Object.keys(environment).find((key) => key.toLowerCase() === "path") ?? + "PATH"; + environment[pathKey] = `${bin}${delimiter}${environment[pathKey] ?? ""}`; + } + + const executable = process.platform === "win32" ? "powershell.exe" : "pwsh"; + return spawnSync( + executable, + ["-NoProfile", "-NonInteractive", "-File", preflightScript], + { + cwd: root, + encoding: "utf8", + env: environment, + timeout: 30_000, + }, + ); +} + +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"); @@ -57,13 +180,42 @@ test("native runner validates the exact repository MSRV before caching", () => { 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\.0$/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 come from PATH or the versioned runner contract", () => { +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("tool discovery supports PATH and the versioned runner contract", () => { const native = jobSource("native-whisper"); assert.match( @@ -83,13 +235,23 @@ test("direct tools come from PATH or the versioned runner contract", () => { assert.doesNotMatch(native, /\.cargo\\bin/); }); -test("rustup-managed PATH proxies use the exact preinstalled toolchain", () => { +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 $contractBin", + ); - assert.match(native, /^\s+RUSTUP_TOOLCHAIN: 1\.89\.0$/m); + 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, /missing rustup\.exe/); assert.doesNotMatch(native, /rustup toolchain list/); assert.doesNotMatch(native, /'\+stable'/); }); @@ -137,6 +299,17 @@ test("native runner contains no Rust installer action", () => { ); }); +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"); diff --git a/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index e438cba0..9ea68e5a 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -386,7 +386,7 @@ isProject: false - 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 +### 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. From 728302691c96064ca47d10ed6fd3a0ef897668ac Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 14:55:39 -0700 Subject: [PATCH 70/86] Harden prepared persistence names Give each process a stable random namespace for profile preparation files so reused process identifiers do not collide with crash residue. Retry exclusive creation within a fixed budget, preserve foreign residue, and report the exhausted target and final candidate. Transfer temporary-path ownership on rename so rollback and commit cleanup cannot remove a later owner's file. Keep persisted configuration bytes and the configuration format unchanged. - `PERSISTENCE_NAMES` combines the process ID, a lazily initialized full-width random nonce, and an atomic per-process sequence. The nonce is formatted as 32 lowercase hexadecimal digits. - `create_prepared` retries only `AlreadyExists` collisions for `PREPARED_CREATE_ATTEMPTS` and returns other I/O errors immediately. Exhaustion preserves every colliding path and reports the target, attempt count, final candidate, and source error through `PreparedCreateExhausted`. - `PreparedFile` stores its owned temporary path in `Option`. `Drop` removes only a still-owned rollback path, while `commit` clears ownership after a successful rename. - `config_write.rs` leaves the config payload and destination schema unchanged. Tests cover deterministic collisions, PID reuse, bounded exhaustion, foreign residue preservation, rollback cleanup, and post-commit path reuse. Design: extends global-state @ crates/gateway/src/config_write.rs::PERSISTENCE_NAMES Design: extends encapsulated-invariant @ crates/gateway/src/config_write.rs::PreparedFile boundary: persisted Design: hidden-dependency -> pure-function @ crates/gateway/src/config_write.rs::persistence_temporary deps: &Path,u128,u32,u64 Design: extends oversized-unit @ crates/gateway/src/config_write.rs Violates: A116 - PreparedFile publication consistency with live state is not determinable from diff Violates: A117 - PreparedFile routing availability during switch preparation is not determinable from diff Plan: vibe/2026-09-07-1-promptforge-debt.md --- crates/gateway/src/config_write.rs | 375 ++++++++++++++++++++++++-- vibe/2026-09-07-1-promptforge-debt.md | 2 +- vibe/archdoc-next.md | 6 +- 3 files changed, 363 insertions(+), 20 deletions(-) diff --git a/crates/gateway/src/config_write.rs b/crates/gateway/src/config_write.rs index 4ff8f7f2..5cf8abb0 100644 --- a/crates/gateway/src/config_write.rs +++ b/crates/gateway/src/config_write.rs @@ -12,39 +12,85 @@ use std::io::Write as _; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{LazyLock, OnceLock}; use axum::Json; use axum::extract::State; use axum::extract::rejection::JsonRejection; use gateway_config::{ConfigErrorKind, save_config_shadow}; +use rand::Rng as _; use crate::auth::Caller; use crate::error::GatewayError; use crate::{AppState, check_auth}; -static PERSISTENCE_SEQUENCE: AtomicU64 = AtomicU64::new(0); +const PREPARED_CREATE_ATTEMPTS: u64 = 16; +static PERSISTENCE_NAMES: LazyLock u128>> = + LazyLock::new(|| ProcessPreparationNames::new(std::process::id(), random_persistence_nonce)); + +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)] pub(crate) struct PreparedFile { target: PathBuf, - temporary: PathBuf, + temporary: Option, original: Option>, contents: Vec, } impl PreparedFile { pub(crate) fn prepare(target: PathBuf, contents: String) -> Result { - let temporary = persistence_temporary(&target); + 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 = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&temporary) + 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()) @@ -56,14 +102,22 @@ impl PreparedFile { } Ok(Self { target, - temporary, + temporary: Some(temporary), original, contents: contents.into_bytes(), }) } pub(crate) fn commit(&mut self) -> Result<(), std::io::Error> { - std::fs::rename(&self.temporary, &self.target) + 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(()) } pub(crate) fn still_original(&self) -> bool { @@ -84,25 +138,94 @@ impl PreparedFile { #[cfg(test)] pub(crate) fn discard_temporary(&self) { - std::fs::remove_file(&self.temporary).expect("prepared temporary exists"); + 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) { - let _ = std::fs::remove_file(&self.temporary); + 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 persistence_temporary(target: &Path) -> PathBuf { +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-{}-{}", - std::process::id(), - PERSISTENCE_SEQUENCE.fetch_add(1, Ordering::Relaxed) - )); + name.push(format!(".prepared-{pid}-{nonce:032x}-{sequence}")); target.with_file_name(name) } @@ -297,6 +420,224 @@ models = ["beta-model"] (temp, config, paths) } + #[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 super::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" + ); + } + #[tokio::test] async fn pending_active_profile_does_not_switch_before_apply() { let (_temp, config, paths) = fixture(); diff --git a/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index 9ea68e5a..37225b35 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -397,7 +397,7 @@ isProject: false - 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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 9b9217e5..da3c72d6 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -164,8 +164,8 @@ N35 | observation | hidden-dependency @ crates/gateway-stt/src/generation.rs::un 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 -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 +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 +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 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 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 @@ -197,3 +197,5 @@ N68 | observation | Violates A2 @ crates/gateway-logging/src/worker.rs: credenti 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 From ca3138cede09f03daa866ddcb7a82bdfa0303d48 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 15:22:36 -0700 Subject: [PATCH 71/86] Extract profile switch preparation phases Move profile target resolution, persistence preparation, cutover, and recovery behind private transaction phases. Each phase owns cancellation, cutover locking, and the prior runtime state needed for rollback while root orchestration stages and commits only a completed cutover. Preserve persistence, cancellation, and runtime behavior while leaving terminal outcome types for the next transaction slice. - `profile_switch.rs` now owns target resolution, prepared files, artifact download, cutover, prior-state capture, and restoration. `config_write.rs` returns to request-boundary work, and `lib.rs` drops the moved helpers. - `PreparedPhase` consumes itself through `cut_over` to create `CutoverPhase`. `PriorRuntimeSnapshot` travels with that value, and `run_switch_phases` receives one phase object instead of the preparation parameter cluster. - `prepare` retains cancellation checks around target preparation, download, and persistence. `cut_over` retains `state.switch` locking, inference drain behavior, conditional download ordering, and rollback escalation. - `PreparedPersistence` retains atomic file replacement, determinate and indeterminate failure classification, directory sync, matching-shadow cleanup, and owned temporary cleanup. - `preparation_produces_a_prepared_phase_without_publishing_target` pins pre-cutover routing. `prepared_phase_transitions_once_to_cutover_with_prior_snapshot` pins interim publication and prior-runtime capture. - `into_terminal_parts` returns ownership to the existing terminal path. Staged, committed, rolled-back, indeterminate, and terminal transition values remain deferred. Design: replaces global-state @ crates/gateway/src/profile_switch.rs::PERSISTENCE_NAMES was: crates/gateway/src/config_write.rs::PERSISTENCE_NAMES Design: replaces encapsulated-invariant @ crates/gateway/src/profile_switch.rs::PreparedFile boundary: persisted was: crates/gateway/src/config_write.rs::PreparedFile Design: replaces pure-function @ crates/gateway/src/profile_switch.rs::persistence_temporary deps: &Path,u128,u32,u64 was: crates/gateway/src/config_write.rs::persistence_temporary Design: replaces encapsulated-invariant @ crates/gateway/src/profile_switch.rs::PreparedPersistence boundary: persisted was: crates/gateway/src/lib.rs::PreparedPersistence Design: replaces parameter-object @ crates/gateway/src/profile_switch.rs::PriorRuntimeSnapshot was: crates/gateway/src/lib.rs::CutoverState Design: new encapsulated-invariant @ crates/gateway/src/profile_switch.rs::PreparedPhase Design: new encapsulated-invariant @ crates/gateway/src/profile_switch.rs::CutoverPhase Design: removes shared-parameter-cluster @ crates/gateway/src/lib.rs::prepare_cutover deps: &AppState,&ProfileName,&ProgressTree,&SwitchTarget,&tokio_util::sync::CancellationToken,StatePersistence,StopSet Design: shared-parameter-cluster -> parameter-object @ crates/gateway/src/lib.rs::run_switch_phases deps: &AppState,&ProfileName,profile_switch::PreparedPhase Design: replaces shared-parameter-cluster @ crates/gateway/src/profile_switch.rs::restore_or_shutdown deps: &AppState,&CancellationToken,GatewayError,PriorRuntimeSnapshot was: crates/gateway/src/lib.rs::restore_or_shutdown Design: new oversized-unit @ crates/gateway/src/profile_switch.rs Deferred: staged and terminal outcome values remain in run_switch_phases and commit_switch Plan: vibe/2026-09-07-1-promptforge-debt.md --- crates/gateway/src/config_write.rs | 445 --------- crates/gateway/src/lib.rs | 752 +------------- crates/gateway/src/profile_switch.rs | 1294 +++++++++++++++++++++++++ vibe/2026-09-07-1-promptforge-debt.md | 2 +- vibe/archdoc-next.md | 8 +- 5 files changed, 1343 insertions(+), 1158 deletions(-) create mode 100644 crates/gateway/src/profile_switch.rs diff --git a/crates/gateway/src/config_write.rs b/crates/gateway/src/config_write.rs index 5cf8abb0..219b500d 100644 --- a/crates/gateway/src/config_write.rs +++ b/crates/gateway/src/config_write.rs @@ -9,242 +9,15 @@ //! shadow mechanics live in `gateway-config`; these handlers //! own auth, path resolution, and the JSON-to-TOML boundary. -use std::io::Write as _; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{LazyLock, OnceLock}; - use axum::Json; use axum::extract::State; use axum::extract::rejection::JsonRejection; use gateway_config::{ConfigErrorKind, save_config_shadow}; -use rand::Rng as _; use crate::auth::Caller; use crate::error::GatewayError; use crate::{AppState, check_auth}; -const PREPARED_CREATE_ATTEMPTS: u64 = 16; -static PERSISTENCE_NAMES: LazyLock u128>> = - LazyLock::new(|| ProcessPreparationNames::new(std::process::id(), random_persistence_nonce)); - -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)] -pub(crate) struct PreparedFile { - target: PathBuf, - temporary: Option, - original: Option>, - contents: Vec, -} - -impl PreparedFile { - pub(crate) 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(), - }) - } - - pub(crate) 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(()) - } - - pub(crate) 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, - } - } - - pub(crate) fn has_committed_contents(&self) -> bool { - std::fs::read(&self.target).is_ok_and(|current| current == self.contents) - } - - pub(crate) fn target(&self) -> &Path { - &self.target - } - - #[cfg(test)] - pub(crate) 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" -)] -pub(crate) 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(()) - } -} - /// The `PUT /admin/config` route: bearer-authed, stages the global config /// and optional sibling profile state. /// @@ -420,224 +193,6 @@ models = ["beta-model"] (temp, config, paths) } - #[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 super::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" - ); - } - #[tokio::test] async fn pending_active_profile_does_not_switch_before_apply() { let (_temp, config, paths) = fixture(); diff --git a/crates/gateway/src/lib.rs b/crates/gateway/src/lib.rs index 3cd5ed37..d210538a 100644 --- a/crates/gateway/src/lib.rs +++ b/crates/gateway/src/lib.rs @@ -95,6 +95,7 @@ mod hf; mod model_info; #[cfg(feature = "local")] mod orphans; +mod profile_switch; mod relaunch; mod render; mod reveal; @@ -120,6 +121,11 @@ 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, @@ -151,6 +157,7 @@ use crate::auth::Caller; use crate::error::GatewayError; #[cfg(feature = "local")] use crate::local::LocalRuntime; +use crate::profile_switch::{PersistenceCommitError, PreparedPersistence, SwitchTarget}; use crate::routing::Routing; use crate::wire::{ ChatRequest, EmbeddingRequest, EmbeddingResponse, ModelInfo, RerankRequest, RerankResponse, @@ -645,17 +652,6 @@ fn gateway_realtime_origin_allowed(request: &axum::extract::Request) -> bool { /// 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 @@ -1379,43 +1375,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), -} - -const PROFILE_STAGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); - -struct PreparedPersistence { - files: Vec, - captures: Vec, -} - -enum PersistenceCommitError { - Determinate(GatewayError), - Indeterminate(GatewayError), -} - -struct CutoverState { - 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, -} - #[derive(Debug)] enum CommitFailure { Determinate(GatewayError), @@ -1446,104 +1405,6 @@ fn classify_speech_stage_failure(error: gateway_stt::SpeechError) -> RuntimeStag } } -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(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)| config_write::PreparedFile::prepare(target, contents)) - .collect::, _>>() - }) - .await - .map_err(|join| GatewayError::ConfigWriteIo(Box::new(join)))??; - Ok(Self { files, captures }) - } - - 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(config_write::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", - ))), - )); - } - config_write::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(()) - } -} - /// Executes a switch using an optional catalog parsed by Apply. /// /// The switch runs in five phases and holds the `switch` lock - the one @@ -1603,19 +1464,12 @@ 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 prepared = + profile_switch::prepare(&state, name.clone(), tree, candidate, persistence, token).await?; + let outcome = run_switch_phases(&state, &name, prepared).await; let report = match outcome { Ok(report) => report, Err(error) => return Err(error), @@ -1636,84 +1490,30 @@ async fn run_switch_with_config( Ok(name.to_string()) } -/// 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 prepare_cutover( - state: &AppState, - name: &ProfileName, - tree: &ProgressTree, - target: &SwitchTarget, - stop: StopSet, - persistence: StatePersistence, - token: &tokio_util::sync::CancellationToken, -) -> Result<(PreparedPersistence, CutoverState), GatewayError> { - if stop.is_empty() { - let prepared = PreparedPersistence::prepare(state, name, persistence).await?; - if token.is_cancelled() { - return Err(switch_cancelled(name)); - } - let old = capture_cutover_state(state).await; - if let Err(error) = cut_over(state, target, tree, stop, token).await { - return Err(restore_or_shutdown(state, token, old, error).await); - } - #[cfg(test)] - state.park_at(switch_park::SwitchPhase::Download).await; - if let Err(error) = download_artifacts(target, tree, token).await { - return Err(restore_or_shutdown(state, token, old, error).await); - } - return Ok((prepared, old)); - } - - #[cfg(test)] - state.park_at(switch_park::SwitchPhase::Download).await; - download_artifacts(target, tree, token).await?; - if token.is_cancelled() { - return Err(switch_cancelled(name)); - } - let prepared = PreparedPersistence::prepare(state, name, persistence).await?; - if token.is_cancelled() { - return Err(switch_cancelled(name)); - } - let old = capture_cutover_state(state).await; - if let Err(error) = cut_over(state, target, tree, stop, token).await { - return Err(restore_or_shutdown(state, token, old, error).await); - } - Ok((prepared, old)) -} - async fn run_switch_phases( state: &AppState, name: &ProfileName, - tree: &ProgressTree, - target: SwitchTarget, - persistence: impl FnOnce() -> StatePersistence, - token: &tokio_util::sync::CancellationToken, + prepared: profile_switch::PreparedPhase, ) -> Result { + let cutover = prepared.cut_over().await?; #[cfg(feature = "stt")] - let mut target = target; - #[cfg(not(feature = "stt"))] - let target = target; - let stop = stop_set(state).await; - let (prepared_persistence, old) = - prepare_cutover(state, name, tree, &target, stop, persistence(), token).await?; + let mut cutover = cutover; // Phase boundary: start no replacement children for a cancelled command. - if token.is_cancelled() { - return Err(restore_or_shutdown(state, token, old, switch_cancelled(name)).await); + if cutover.is_cancelled() { + let error = cutover.cancellation_error(); + return Err(cutover.restore_or_shutdown(error).await); } #[cfg(test)] { state.park_at(switch_park::SwitchPhase::Spawn).await; } #[cfg(feature = "stt")] - let Some(prepared_speech) = target.speech.take() else { - let error = GatewayError::switch_failed( - "stage-stt", - std::io::Error::other("speech preparation was already consumed"), - ); - return Err(restore_or_shutdown(state, token, old, error).await); + let prepared_speech = match cutover.take_prepared_speech() { + Ok(speech) => speech, + Err(error) => return Err(cutover.restore_or_shutdown(error).await), }; let deadline = std::time::Instant::now() - .checked_add(PROFILE_STAGE_TIMEOUT) + .checked_add(profile_switch::STAGE_TIMEOUT) .ok_or_else(|| { GatewayError::switch_failed( "stage-profile-deadline", @@ -1724,25 +1524,25 @@ async fn run_switch_phases( ) })?; let replacement = match spawn_runtimes( - &target.config, + cutover.config(), #[cfg(feature = "stt")] state.speech.clone(), #[cfg(feature = "stt")] prepared_speech, - tree, - token, + cutover.tree(), + cutover.token(), deadline, ) .await { Ok(replacement) => replacement, Err(RuntimeStageFailure::Determinate(error)) => { - return Err(restore_or_shutdown(state, token, old, error).await); + return Err(cutover.restore_or_shutdown(error).await); } Err(RuntimeStageFailure::Fatal(error)) => { return Err(request_fatal_shutdown( state, - token, + cutover.token(), "stage-profile-timeout", error, )); @@ -1751,396 +1551,37 @@ async fn run_switch_phases( // 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() { + if cutover.is_cancelled() { if let Err(rollback) = rollback_runtime(state, replacement) { return Err(request_fatal_shutdown( state, - token, + cutover.token(), "rollback-staged-profile", rollback, )); } - return Err(restore_or_shutdown(state, token, old, switch_cancelled(name)).await); + let error = cutover.cancellation_error(); + return Err(cutover.restore_or_shutdown(error).await); } + let (target, prepared_persistence, old, token) = cutover.into_terminal_parts(); match commit_switch( state, name, target, replacement, prepared_persistence, - token, + &token, ) .await { Ok(report) => Ok(report), Err(CommitFailure::Determinate(error)) => { - Err(restore_or_shutdown(state, token, old, error).await) + Err(profile_switch::restore_or_shutdown(state, &token, old, error).await) } Err(CommitFailure::Fatal(error)) => Err(error), } } -/// 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, - #[cfg(feature = "stt")] - speech: Option, -} - -/// 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(), - }; - 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), - )); - } - #[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: Some(speech), - }) -} - -/// Which old runtimes the cut-over must stop. -#[derive(Debug, Clone, Copy)] -struct StopSet { - #[cfg(feature = "local")] - local: bool, - #[cfg(feature = "stt")] - stt: bool, -} - -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 - } -} - -/// 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.speech.status().ready(), - } -} - -/// A headless build runs no local runtime or speech service, so there is never -/// anything to stop. -#[cfg(not(any(feature = "local", feature = "stt")))] -async fn stop_set(_state: &AppState) -> StopSet { - StopSet {} -} - -/// 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()), - }) - } - }; - 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, -} - -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 = "local")] - let result = self.local.shutdown(); - #[cfg(not(feature = "local"))] - let result = Ok(()); - result - } -} - -async fn capture_cutover_state(state: &AppState) -> CutoverState { - let live = state.live.read().await; - CutoverState { - 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_cutover_state(state: &AppState, old: CutoverState) -> Result<(), GatewayError> { - #[cfg(feature = "local")] - let local = if old.restart_local { - let config = Arc::clone(&old.config); - tokio::time::timeout( - PROFILE_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 !old.routing_was_empty { - live.routing = old.routing; - } - live.config = old.config; - #[cfg(feature = "web-search")] - { - live.web_search = old.web_search; - } - live.profile_name = old.profile_name; - live.model_allowlist = old.model_allowlist; - live.loading = old.loading; - #[cfg(feature = "local")] - { - live.local = local; - } - Ok(()) -} - -async fn restore_or_shutdown( - state: &AppState, - token: &tokio_util::sync::CancellationToken, - old: CutoverState, - failure: GatewayError, -) -> GatewayError { - match restore_cutover_state(state, old).await { - Ok(()) => failure, - Err(rollback) => { - token.cancel(); - state.shutdown.fire(); - #[cfg(feature = "stt")] - state.speech.shutdown(); - GatewayError::switch_failed("rollback-profile", rollback) - } - } -} - #[cfg_attr( not(feature = "stt"), expect( @@ -2339,56 +1780,6 @@ struct StartReport { 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")] @@ -3272,48 +2663,6 @@ mod provisioning_tests { } } - #[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 = crate::config_write::PreparedFile::prepare( - target.clone(), - "active_profile = \"beta\"\n".to_owned(), - ) - .expect("prepare determinate fixture"); - determinate.discard_temporary(); - let error = crate::PreparedPersistence { - files: vec![determinate], - captures: Vec::new(), - } - .commit_blocking() - .expect_err("missing temporary prevents commit"); - assert!(matches!( - error, - crate::PersistenceCommitError::Determinate(_) - )); - - let indeterminate = crate::config_write::PreparedFile::prepare( - target.clone(), - "active_profile = \"beta\"\n".to_owned(), - ) - .expect("prepare indeterminate fixture"); - std::fs::write(&target, "unrecognized contents").expect("replace authoritative state"); - indeterminate.discard_temporary(); - let error = crate::PreparedPersistence { - files: vec![indeterminate], - captures: Vec::new(), - } - .commit_blocking() - .expect_err("missing temporary prevents commit"); - assert!(matches!( - error, - crate::PersistenceCommitError::Indeterminate(_) - )); - } - #[cfg(feature = "stt")] #[tokio::test] async fn determinate_commit_with_failed_speech_rollback_requests_shutdown() { @@ -3335,19 +2684,15 @@ mod provisioning_tests { 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 prepared = crate::config_write::PreparedFile::prepare( + let persistence = crate::profile_switch::PreparedPersistence::for_test( target_path, "active_profile = \"beta\"\n".to_owned(), ) .expect("prepare state"); - prepared.discard_temporary(); - let persistence = crate::PreparedPersistence { - files: vec![prepared], - captures: Vec::new(), - }; + persistence.discard_temporaries(); let name = ProfileName::parse("beta").expect("profile name"); let tree = state.hub.operation(); - let target = crate::prepare_switch(&state, &name, &tree, None) + let target = crate::profile_switch::prepare_target_for_test(&state, &name, &tree, None) .await .expect("target prepares"); let replacement = crate::RuntimeReplacement { @@ -3394,21 +2739,17 @@ mod provisioning_tests { 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 prepared = crate::config_write::PreparedFile::prepare( + 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"); - prepared.discard_temporary(); - let persistence = crate::PreparedPersistence { - files: vec![prepared], - captures: Vec::new(), - }; + persistence.discard_temporaries(); let name = ProfileName::parse("beta").expect("profile name"); let tree = state.hub.operation(); - let target = crate::prepare_switch(&state, &name, &tree, None) + let target = crate::profile_switch::prepare_target_for_test(&state, &name, &tree, None) .await .expect("target prepares"); let replacement = crate::RuntimeReplacement { @@ -3465,19 +2806,14 @@ mod provisioning_tests { Duration::from_secs(1), ) .expect("speech stages"); - let persistence = crate::PreparedPersistence { - files: vec![ - crate::config_write::PreparedFile::prepare( - state_path.clone(), - "active_profile = \"beta\"\n".to_owned(), - ) - .expect("prepare state"), - ], - captures: Vec::new(), - }; + 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::prepare_switch(&state, &name, &tree, None) + let target = crate::profile_switch::prepare_target_for_test(&state, &name, &tree, None) .await .expect("target prepares"); let replacement = crate::RuntimeReplacement { diff --git a/crates/gateway/src/profile_switch.rs b/crates/gateway/src/profile_switch.rs new file mode 100644 index 00000000..33b61452 --- /dev/null +++ b/crates/gateway/src/profile_switch.rs @@ -0,0 +1,1294 @@ +//! Private profile-switch preparation transaction. +//! +//! The prepared and cutover values own each phase's resources, so runtime +//! staging cannot begin before target preparation and interim publication. + +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 = "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: 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, +} + +impl PreparedPhase { + /// Consumes the prepared phase and produces the only value that can enter + /// runtime staging. + pub(super) 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(restore_or_shutdown(&self.state, &self.token, 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.restore_or_shutdown(error).await), + } + } + Ok(cutover) + } +} + +impl CutoverPhase { + /// Reports cancellation through the transaction-owned token. + pub(super) fn is_cancelled(&self) -> bool { + self.token.is_cancelled() + } + + /// Builds the profile-specific cancellation result. + pub(super) fn cancellation_error(&self) -> GatewayError { + switch_cancelled(&self.name) + } + + /// Borrows the transaction-owned cancellation token. + pub(super) fn token(&self) -> &CancellationToken { + &self.token + } + + /// Borrows the selected target configuration for runtime staging. + pub(super) fn config(&self) -> &Config { + &self.target.config + } + + /// Borrows the operation tree for runtime staging. + pub(super) fn tree(&self) -> &ProgressTree { + &self.tree + } + + #[cfg(feature = "stt")] + /// Transfers prepared speech into runtime staging exactly once. + pub(super) fn take_prepared_speech( + &mut self, + ) -> Result { + self.target.speech.take().ok_or_else(|| { + GatewayError::switch_failed( + "stage-stt", + std::io::Error::other("speech preparation was already consumed"), + ) + }) + } + + /// Restores the prior runtime or requests controlled shutdown. + pub(super) async fn restore_or_shutdown(self, failure: GatewayError) -> GatewayError { + restore_or_shutdown(&self.state, &self.token, self.prior, failure).await + } + + /// Hands resources to the unchanged terminal staging and commit path. + pub(super) fn into_terminal_parts( + self, + ) -> ( + SwitchTarget, + PreparedPersistence, + PriorRuntimeSnapshot, + CancellationToken, + ) { + (self.target, self.persistence, self.prior, self.token) + } +} + +/// 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: Some(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(test)] +/// 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 { + prepare_target(state, name, tree, candidate).await +} + +#[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(()) +} + +/// Restores a prior snapshot, escalating failed restoration to shutdown. +pub(super) async fn restore_or_shutdown( + state: &AppState, + token: &CancellationToken, + prior: PriorRuntimeSnapshot, + failure: GatewayError, +) -> GatewayError { + match restore_runtime_snapshot(state, prior).await { + Ok(()) => failure, + Err(rollback) => { + token.cancel(); + state.shutdown.fire(); + #[cfg(feature = "stt")] + state.speech.shutdown(); + GatewayError::switch_failed("rollback-profile", rollback) + } + } +} + +#[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/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index 37225b35..aad43a65 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -407,7 +407,7 @@ isProject: false - 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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index da3c72d6..a98a9995 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -164,12 +164,12 @@ N35 | observation | hidden-dependency @ crates/gateway-stt/src/generation.rs::un 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 +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 -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 +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 -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 +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 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 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 From 16741b6e98b6b9edd1be6e4f944fc6258a96a8ee Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 15:40:49 -0700 Subject: [PATCH 72/86] Complete the profile-switch transaction Move staging, commit, rollback, and fatal shutdown behind consuming transaction phases, and reduce the Gateway root to one delegation. Preserve cancellation gates and switch and publication lock order while persistence still precedes atomic live-state publication. Add direct terminal coverage and keep the outward switch result stable with or without optional runtimes. - `StagedPhase`, `CommitTail`, `PublicationPhase`, and `TerminalPhase` consume success state in order. `RollbackOwner` owns staged rollback and restoration of `PriorRuntimeSnapshot`, while `IndeterminatePhase` owns controlled shutdown when state cannot be proven. - `run_switch_with_config` delegates to `profile_switch::run`. `StagedPhase::commit` checks cancellation around `switch` and `apply`, restores determinate failures, and routes indeterminate staging, persistence, rollback, and speech publication through `request_fatal_shutdown`. - `featureless_cancellation_stops_persistence_and_publication` uses `Spawn` instead of waiting for the absent `starting-models` leaf. `featureless_profile_switch_commits_the_complete_target`, `indeterminate_staging_timeout_requests_shutdown_without_persisting`, and `failed_speech_publication_is_indeterminate_after_persistence` drive terminal outcomes through the root entry point. - `web_search`, `fake_brave`, and `gateway_with_web_search` share the `web-search` boundary so featureless tests compile without changing feature-enabled coverage. Design: new facade @ crates/gateway/src/profile_switch.rs::run deps: &AppState,&CancellationToken,Option,ProfileName,ProgressTree,impl FnOnce() -> StatePersistence Design: new encapsulated-invariant @ crates/gateway/src/profile_switch.rs::StagedPhase Design: new encapsulated-invariant @ crates/gateway/src/profile_switch.rs::PublicationPhase Design: new parameter-object @ crates/gateway/src/profile_switch.rs::RollbackOwner Design: removes parameter-object @ crates/gateway/src/lib.rs::run_switch_phases deps: &AppState,&ProfileName,profile_switch::PreparedPhase Design: removes shared-parameter-cluster @ crates/gateway/src/lib.rs::commit_switch deps: &AppState,&ProfileName,&tokio_util::sync::CancellationToken,PreparedPersistence,RuntimeReplacement,SwitchTarget Design: removes oversized-unit @ crates/gateway/src/lib.rs::commit_switch Design: removes shared-parameter-cluster @ crates/gateway/src/lib.rs::request_fatal_shutdown deps: &'static str,&AppState,&tokio_util::sync::CancellationToken,GatewayError Design: removes shared-parameter-cluster @ crates/gateway/src/lib.rs::rollback_commit_failure deps: &AppState,&tokio_util::sync::CancellationToken,GatewayError,RuntimeReplacement Design: removes shared-parameter-cluster @ crates/gateway/src/profile_switch.rs::restore_or_shutdown deps: &AppState,&CancellationToken,GatewayError,PriorRuntimeSnapshot Design: extends oversized-unit @ crates/gateway/src/profile_switch.rs Violates: A2 - credential ownership in crates/gateway/src/profile_switch.rs is not determinable from diff Plan: vibe/2026-09-07-1-promptforge-debt.md --- crates/gateway/src/lib.rs | 809 ++++++++----------------- crates/gateway/src/profile_switch.rs | 823 ++++++++++++++++++++++++-- crates/gateway/tests/it/main.rs | 1 + crates/gateway/tests/it/support.rs | 2 + vibe/2026-09-07-1-promptforge-debt.md | 2 +- vibe/archdoc-next.md | 7 +- 6 files changed, 1023 insertions(+), 621 deletions(-) diff --git a/crates/gateway/src/lib.rs b/crates/gateway/src/lib.rs index d210538a..a1603fdf 100644 --- a/crates/gateway/src/lib.rs +++ b/crates/gateway/src/lib.rs @@ -157,7 +157,6 @@ use crate::auth::Caller; use crate::error::GatewayError; #[cfg(feature = "local")] use crate::local::LocalRuntime; -use crate::profile_switch::{PersistenceCommitError, PreparedPersistence, SwitchTarget}; use crate::routing::Routing; use crate::wire::{ ChatRequest, EmbeddingRequest, EmbeddingResponse, ModelInfo, RerankRequest, RerankResponse, @@ -166,7 +165,7 @@ use gateway_config::ModelKind; #[cfg(feature = "web-search")] use gateway_config::WebSearchConfig; #[cfg(feature = "stt")] -use gateway_stt::{SpeechReplacement, SpeechService}; +use gateway_stt::SpeechService; #[cfg(feature = "web-search")] use gateway_web_search::{WebSearchRequest, WebSearchResponse, WebSearchState}; use shared_progress::{EventState, OperationId, ProgressEvent, ProgressHub, ProgressTree}; @@ -295,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`]. @@ -317,6 +320,13 @@ pub(crate) mod switch_park { 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: /// each notify stores one permit, so a release before the switch /// arrives is not lost. @@ -365,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( @@ -423,6 +438,8 @@ impl AppState { speech, #[cfg(test)] park: None, + #[cfg(test)] + switch_fault: None, } } @@ -1375,36 +1392,6 @@ async fn admin_switch_profile( Ok(switch_sse_response(rx, enqueued.operation, switch)) } -#[derive(Debug)] -enum CommitFailure { - Determinate(GatewayError), - Fatal(GatewayError), -} - -#[derive(Debug)] -#[cfg_attr( - not(any(feature = "local", feature = "stt")), - expect( - dead_code, - reason = "the featureless stage stub cannot produce either runtime failure classification" - ) -)] -enum RuntimeStageFailure { - Determinate(GatewayError), - Fatal(GatewayError), -} - -#[cfg(feature = "stt")] -fn classify_speech_stage_failure(error: gateway_stt::SpeechError) -> RuntimeStageFailure { - let fatal = error.is_non_preemptible_startup_timeout(); - let error = GatewayError::switch_failed("start-stt", error); - if fatal { - RuntimeStageFailure::Fatal(error) - } else { - RuntimeStageFailure::Determinate(error) - } -} - /// Executes a switch using an optional catalog parsed by Apply. /// /// The switch runs in five phases and holds the `switch` lock - the one @@ -1464,461 +1451,7 @@ async fn run_switch_with_config( persistence: impl FnOnce() -> StatePersistence, token: &tokio_util::sync::CancellationToken, ) -> Result { - // 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 prepared = - profile_switch::prepare(&state, name.clone(), tree, candidate, persistence, token).await?; - let outcome = run_switch_phases(&state, &name, prepared).await; - let report = match outcome { - Ok(report) => report, - Err(error) => 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; - - tracing::info!(profile = %name, "switched profile"); - Ok(name.to_string()) -} - -async fn run_switch_phases( - state: &AppState, - name: &ProfileName, - prepared: profile_switch::PreparedPhase, -) -> Result { - let cutover = prepared.cut_over().await?; - #[cfg(feature = "stt")] - let mut cutover = cutover; - // Phase boundary: start no replacement children for a cancelled command. - if cutover.is_cancelled() { - let error = cutover.cancellation_error(); - return Err(cutover.restore_or_shutdown(error).await); - } - #[cfg(test)] - { - state.park_at(switch_park::SwitchPhase::Spawn).await; - } - #[cfg(feature = "stt")] - let prepared_speech = match cutover.take_prepared_speech() { - Ok(speech) => speech, - Err(error) => return Err(cutover.restore_or_shutdown(error).await), - }; - let deadline = std::time::Instant::now() - .checked_add(profile_switch::STAGE_TIMEOUT) - .ok_or_else(|| { - GatewayError::switch_failed( - "stage-profile-deadline", - std::io::Error::new( - std::io::ErrorKind::TimedOut, - "profile staging deadline could not be represented", - ), - ) - })?; - let replacement = match spawn_runtimes( - cutover.config(), - #[cfg(feature = "stt")] - state.speech.clone(), - #[cfg(feature = "stt")] - prepared_speech, - cutover.tree(), - cutover.token(), - deadline, - ) - .await - { - Ok(replacement) => replacement, - Err(RuntimeStageFailure::Determinate(error)) => { - return Err(cutover.restore_or_shutdown(error).await); - } - Err(RuntimeStageFailure::Fatal(error)) => { - return Err(request_fatal_shutdown( - state, - cutover.token(), - "stage-profile-timeout", - error, - )); - } - }; - // 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 cutover.is_cancelled() { - if let Err(rollback) = rollback_runtime(state, replacement) { - return Err(request_fatal_shutdown( - state, - cutover.token(), - "rollback-staged-profile", - rollback, - )); - } - let error = cutover.cancellation_error(); - return Err(cutover.restore_or_shutdown(error).await); - } - let (target, prepared_persistence, old, token) = cutover.into_terminal_parts(); - match commit_switch( - state, - name, - target, - replacement, - prepared_persistence, - &token, - ) - .await - { - Ok(report) => Ok(report), - Err(CommitFailure::Determinate(error)) => { - Err(profile_switch::restore_or_shutdown(state, &token, old, error).await) - } - Err(CommitFailure::Fatal(error)) => Err(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(()) -} - -fn request_fatal_shutdown( - state: &AppState, - token: &tokio_util::sync::CancellationToken, - phase: &'static str, - error: GatewayError, -) -> GatewayError { - token.cancel(); - state.shutdown.fire(); - #[cfg(feature = "stt")] - state.speech.shutdown(); - GatewayError::switch_failed(phase, error) -} - -fn rollback_commit_failure( - state: &AppState, - token: &tokio_util::sync::CancellationToken, - replacement: RuntimeReplacement, - failure: GatewayError, -) -> CommitFailure { - match rollback_runtime(state, replacement) { - Ok(()) => CommitFailure::Determinate(failure), - Err(rollback) => CommitFailure::Fatal(request_fatal_shutdown( - state, - token, - "rollback-staged-profile", - GatewayError::switch_failed( - "determinate-profile-failure", - std::io::Error::other(format!( - "{}; {}", - config_write::error_chain(&failure), - config_write::error_chain(&rollback) - )), - ), - )), - } -} - -/// 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: PreparedPersistence, - 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 = match target - .remote_routing - .merge(replacement.local.models().iter().cloned()) - { - Ok(routing) => routing, - Err(error) => { - let failure = GatewayError::switch_failed("merge-routing", error); - return Err(rollback_commit_failure(state, token, replacement, failure)); - } - }; - #[cfg(not(feature = "local"))] - let routing = target.remote_routing; - if token.is_cancelled() { - return Err(rollback_commit_failure( - state, - token, - replacement, - GatewayError::CommandCancelled("profile switch".to_owned()), - )); - } - let _publication = tokio::select! { - biased; - () = token.cancelled() => { - return Err(rollback_commit_failure( - state, - token, - replacement, - GatewayError::CommandCancelled("profile switch".to_owned()), - )); - } - guard = state.apply.lock() => guard, - }; - if token.is_cancelled() { - return Err(rollback_commit_failure( - state, - token, - replacement, - GatewayError::CommandCancelled("profile switch".to_owned()), - )); - } - match persistence.commit().await { - Ok(()) => {} - Err(PersistenceCommitError::Determinate(error)) => { - return Err(rollback_commit_failure(state, token, replacement, error)); - } - Err(PersistenceCommitError::Indeterminate(error)) => { - return Err(CommitFailure::Fatal(request_fatal_shutdown( - state, - token, - "persist-profile-indeterminate", - error, - ))); - } - } - #[cfg(test)] - { - state.park_at(switch_park::SwitchPhase::Publish).await; - } - let report = start_report(&replacement); - #[cfg(feature = "stt")] - if let Err(error) = state.speech.commit_replacement(replacement.speech) { - return Err(CommitFailure::Fatal(request_fatal_shutdown( - state, - token, - "publish-stt", - GatewayError::switch_failed("publish-stt", error), - ))); - } - - // 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; - } - live.profile_name = Some(name.to_string()); - live.model_allowlist = target.allowlist; - live.loading.clear(); - Ok(report) -} - -#[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 {} -} - -/// What the spawn reported once the commit landed: the local models that -/// reached readiness and the ones that failed, rendered for -/// [`GatewayError::PartialStart`]. -#[derive(Debug)] -struct StartReport { - #[cfg(feature = "local")] - loaded: Vec, - #[cfg(feature = "local")] - failed: Vec, -} - -/// 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")] - speech: SpeechReplacement, -} - -/// Phase 4 in a headless build: no local runtime or speech generation exists, -/// 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, - _deadline: std::time::Instant, -) -> Result { - Ok(RuntimeReplacement {}) -} - -/// Phase 4: starts the target's local children and staged speech generation 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")] speech: SpeechService, - #[cfg(feature = "stt")] prepared_speech: gateway_stt::PreparedSpeech, - tree: &ProgressTree, - token: &tokio_util::sync::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 = { - // 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::time::timeout( - deadline.saturating_duration_since(std::time::Instant::now()), - 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(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::Fatal(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(); - // Phase boundary: a cancelled command starts no speech generation behind the - // cancellation; the local runtime built above drops, killing its - // children. - #[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, - }) + profile_switch::run(&state, name, tree, candidate, persistence, token).await } /// Builds the switch-profile SSE response: the hub's event stream filtered @@ -2458,51 +1991,42 @@ 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!( @@ -2512,9 +2036,12 @@ mod provisioning_tests { "the late cancellation stops the switch: {outcome:?}" ); let live = state.live.read().await; - assert!( - live.profile_name.is_none(), - "the cancelled switch never committed the profile" + 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(), @@ -2546,6 +2073,62 @@ mod provisioning_tests { 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_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" + ); + assert!(!token.is_cancelled()); + assert!(!state.shutdown.is_fired()); + } + /// Runs the switch to `profile` on its own task with no persistence. fn spawn_switch( state: &AppState, @@ -2663,6 +2246,98 @@ mod provisioning_tests { } } + #[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() { @@ -2695,7 +2370,7 @@ mod provisioning_tests { let target = crate::profile_switch::prepare_target_for_test(&state, &name, &tree, None) .await .expect("target prepares"); - let replacement = crate::RuntimeReplacement { + let replacement = crate::profile_switch::RuntimeReplacement { #[cfg(feature = "local")] local: crate::local::LocalRuntime::empty(), #[cfg(feature = "local")] @@ -2704,12 +2379,16 @@ mod provisioning_tests { }; let token = CancellationToken::new(); - let error = crate::commit_switch(&state, &name, target, replacement, persistence, &token) - .await - .expect_err("failed rollback makes a determinate persistence failure fatal"); - let crate::CommitFailure::Fatal(error) = error else { - panic!("failed rollback must be fatal"); - }; + 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!( @@ -2752,7 +2431,7 @@ mod provisioning_tests { let target = crate::profile_switch::prepare_target_for_test(&state, &name, &tree, None) .await .expect("target prepares"); - let replacement = crate::RuntimeReplacement { + let replacement = crate::profile_switch::RuntimeReplacement { #[cfg(feature = "local")] local: crate::local::LocalRuntime::empty(), #[cfg(feature = "local")] @@ -2761,10 +2440,16 @@ mod provisioning_tests { }; let token = CancellationToken::new(); - let error = crate::commit_switch(&state, &name, target, replacement, persistence, &token) - .await - .expect_err("indeterminate persistence is fatal"); - assert!(matches!(error, crate::CommitFailure::Fatal(_))); + 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"); @@ -2816,7 +2501,7 @@ mod provisioning_tests { let target = crate::profile_switch::prepare_target_for_test(&state, &name, &tree, None) .await .expect("target prepares"); - let replacement = crate::RuntimeReplacement { + let replacement = crate::profile_switch::RuntimeReplacement { #[cfg(feature = "local")] local: crate::local::LocalRuntime::empty(), #[cfg(feature = "local")] @@ -2831,13 +2516,13 @@ mod provisioning_tests { let commit_state = state.clone(); let commit_token = token.clone(); let commit = tokio::spawn(async move { - crate::commit_switch( + crate::profile_switch::commit_for_test( &commit_state, - &name, + name, target, replacement, persistence, - &commit_token, + commit_token, ) .await }); @@ -2979,7 +2664,8 @@ mod provisioning_tests { ) .expect("next construction reaches the blocked scenario"); let error = result.expect_err("parked native-equivalent startup times out"); - let crate::RuntimeStageFailure::Fatal(error) = crate::classify_speech_stage_failure(error) + let crate::profile_switch::RuntimeStageFailure::Indeterminate(error) = + crate::profile_switch::classify_speech_stage_failure(error) else { panic!("non-preemptible speech timeout must be fatal"); }; @@ -2987,7 +2673,12 @@ mod provisioning_tests { state.speech = service; let token = CancellationToken::new(); - let _error = crate::request_fatal_shutdown(&state, &token, "stage-profile-timeout", error); + let _error = crate::profile_switch::request_fatal_shutdown( + &state, + &token, + "stage-profile-timeout", + error, + ); assert!(token.is_cancelled()); assert!(state.shutdown.is_fired()); @@ -3016,7 +2707,7 @@ mod provisioning_tests { .expect("speech stages"); let token = CancellationToken::new(); - let _error = crate::request_fatal_shutdown( + let _error = crate::profile_switch::request_fatal_shutdown( &state, &token, "fatal-test", @@ -3888,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"), @@ -3903,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/profile_switch.rs b/crates/gateway/src/profile_switch.rs index 33b61452..0e04ca19 100644 --- a/crates/gateway/src/profile_switch.rs +++ b/crates/gateway/src/profile_switch.rs @@ -1,7 +1,7 @@ -//! Private profile-switch preparation transaction. +//! Private profile-switch transaction. //! -//! The prepared and cutover values own each phase's resources, so runtime -//! staging cannot begin before target preparation and interim publication. +//! 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 _; @@ -19,6 +19,8 @@ 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; @@ -407,7 +409,16 @@ pub(super) struct SwitchTarget { pub(super) allowlist: Option>, loading: BTreeSet, #[cfg(feature = "stt")] - speech: Option, + 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)] @@ -467,10 +478,122 @@ pub(super) struct CutoverPhase { 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. - pub(super) async fn cut_over(self) -> Result { + async fn cut_over(self) -> Result { let prior = capture_runtime_snapshot(&self.state).await; if let Err(error) = cut_over( &self.state, @@ -481,7 +604,7 @@ impl PreparedPhase { ) .await { - return Err(restore_or_shutdown(&self.state, &self.token, prior, error).await); + return Err(self.roll_back(prior, error).await); } let cutover = CutoverPhase { state: self.state, @@ -500,70 +623,393 @@ impl PreparedPhase { .await; match download_artifacts(&cutover.target, &cutover.tree, &cutover.token).await { Ok(()) => {} - Err(error) => return Err(cutover.restore_or_shutdown(error).await), + 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 { - /// Reports cancellation through the transaction-owned token. - pub(super) fn is_cancelled(&self) -> bool { - self.token.is_cancelled() + 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) } - /// Builds the profile-specific cancellation result. - pub(super) fn cancellation_error(&self) -> GatewayError { - switch_cancelled(&self.name) + 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, + ), + }), + } } +} - /// Borrows the transaction-owned cancellation token. - pub(super) fn token(&self) -> &CancellationToken { - &self.token +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, + } } - /// Borrows the selected target configuration for runtime staging. - pub(super) fn config(&self) -> &Config { - &self.target.config + 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, + ), + }), + } } - /// Borrows the operation tree for runtime staging. - pub(super) fn tree(&self) -> &ProgressTree { - &self.tree + fn into_indeterminate(self, phase: &'static str, failure: GatewayError) -> TerminalPhase { + TerminalPhase::Indeterminate(IndeterminatePhase { + error: request_fatal_shutdown(&self.state, &self.token, phase, failure), + }) } +} - #[cfg(feature = "stt")] - /// Transfers prepared speech into runtime staging exactly once. - pub(super) fn take_prepared_speech( - &mut self, - ) -> Result { - self.target.speech.take().ok_or_else(|| { - GatewayError::switch_failed( - "stage-stt", - std::io::Error::other("speech preparation was already consumed"), - ) +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), }) } - /// Restores the prior runtime or requests controlled shutdown. - pub(super) async fn restore_or_shutdown(self, failure: GatewayError) -> GatewayError { - restore_or_shutdown(&self.state, &self.token, self.prior, failure).await + 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, + } } +} - /// Hands resources to the unchanged terminal staging and commit path. - pub(super) fn into_terminal_parts( - self, - ) -> ( - SwitchTarget, - PreparedPersistence, - PriorRuntimeSnapshot, - CancellationToken, - ) { - (self.target, self.persistence, self.prior, self.token) +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, @@ -663,7 +1109,7 @@ async fn prepare_target( allowlist, loading, #[cfg(feature = "stt")] - speech: Some(speech), + speech, }) } @@ -709,15 +1155,30 @@ fn switch_cancelled(name: &ProfileName) -> GatewayError { GatewayError::CommandCancelled(format!("load-profile: {name}")) } -#[cfg(test)] +#[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 { - prepare_target(state, name, tree, candidate).await +) -> 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"))] @@ -939,23 +1400,265 @@ async fn restore_runtime_snapshot( Ok(()) } -/// Restores a prior snapshot, escalating failed restoration to shutdown. -pub(super) async fn restore_or_shutdown( +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, -) -> GatewayError { - match restore_runtime_snapshot(state, prior).await { - Ok(()) => failure, - Err(rollback) => { - token.cancel(); - state.shutdown.fire(); - #[cfg(feature = "stt")] - state.speech.shutdown(); - GatewayError::switch_failed("rollback-profile", rollback) + 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)] diff --git a/crates/gateway/tests/it/main.rs b/crates/gateway/tests/it/main.rs index 2a5c8c2e..a85f9bd0 100644 --- a/crates/gateway/tests/it/main.rs +++ b/crates/gateway/tests/it/main.rs @@ -39,4 +39,5 @@ mod realtime_stt; mod rerank; mod sidecar; mod surface; +#[cfg(feature = "web-search")] mod web_search; 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/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index aad43a65..a9edbecd 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -417,7 +417,7 @@ isProject: false - 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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index a98a9995..d824ae84 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -168,10 +168,10 @@ N39 | observation | global-state @ crates/gateway/src/config_write.rs::PERSISTEN 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 +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 -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 +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 @@ -199,3 +199,4 @@ N70 | observation | Violates A2 @ crates/gateway-stt-engine/src/test_fixtures: c 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 From 9cf95e75c02fe9ab851ff56a20b9b64e0e50e9e0 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 16:21:00 -0700 Subject: [PATCH 73/86] Decode Realtime events exhaustively Route every Realtime server frame through one pure decoder before production dispatch. Enforce exact event shapes and semantic constraints while preserving isolated delta fallback across reconnects. - `RealtimeEvent` and `decodeRealtimeEvent` define the supported discriminated union and reject unknown types, extra or missing fields, malformed nullable values, invalid identifiers and indices, unsafe revisions, inconsistent transcript partitions, reversed audio spans, and invalid duration usage. - `RealtimeTranscriptionService` dispatches only validated events and sends malformed server values through the recoverable session error path. - `realtime-wire-fixtures.mjs` mutates every canonical field and replays every canonical sequence through the production decoder. `stt-stream.mjs` pins production rejection, reconnect reset, per-item delta assembly, and hypothesis takeover. Design: new surface-growth @ crates/workshop-server/ui/src/services/realtime-event-decoder.ts::RealtimeEvent boundary: pub Design: new pure-function @ crates/workshop-server/ui/src/services/realtime-event-decoder.ts::decodeRealtimeEvent deps: unknown boundary: pub Design: new dispatch-on-tag @ crates/workshop-server/ui/src/services/realtime-event-decoder.ts::decodeRealtimeEvent deps: unknown boundary: pub Design: new oversized-unit @ crates/workshop-server/ui/src/services/realtime-event-decoder.ts::decodeRealtimeEvent deps: unknown boundary: pub Design: extends surface-growth @ crates/workshop-server/ui/src/services/realtime-transcription.ts boundary: pub Design: extends dispatch-on-tag @ crates/workshop-server/ui/src/services/realtime-transcription.ts::handleMessage Violates: A96 - bounded third-party model content in crates/workshop-server/ui/src/services/realtime-event-decoder.ts is not determinable from diff Pending: N53 - compounds Pending: N55 - compounds Plan: vibe/2026-09-07-1-promptforge-debt.md --- .../ui/src/services/realtime-event-decoder.ts | 393 ++++++++++++++++++ .../ui/src/services/realtime-transcription.ts | 182 ++++---- crates/workshop-server/ui/test/agent-stt.mjs | 10 +- .../workshop-server/ui/test/helpers/boot.mjs | 33 +- .../ui/test/realtime-wire-fixtures.mjs | 170 ++++++++ crates/workshop-server/ui/test/stt-stream.mjs | 181 ++++++++ vibe/2026-09-07-1-promptforge-debt.md | 2 +- vibe/archdoc-next.md | 6 +- 8 files changed, 849 insertions(+), 128 deletions(-) create mode 100644 crates/workshop-server/ui/src/services/realtime-event-decoder.ts 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 index 313d4247..89312e57 100644 --- a/crates/workshop-server/ui/src/services/realtime-transcription.ts +++ b/crates/workshop-server/ui/src/services/realtime-transcription.ts @@ -1,5 +1,6 @@ import { Emitter, type Event as ServiceEvent } from "../base/event"; import { Disposable } from "../base/lifecycle"; +import { decodeRealtimeEvent } from "./realtime-event-decoder"; const HYPOTHESIS_INCLUDE = "item.input_audio_transcription.hypothesis"; const RECONNECT_INITIAL_MS = 1000; @@ -72,16 +73,6 @@ function socketUrl(): string { return `${scheme}://${location.host}/v1/realtime`; } -function objectValue(value: unknown): Record | null { - return typeof value === "object" && value !== null - ? (value as Record) - : null; -} - -function nonemptyString(value: unknown): string | null { - return typeof value === "string" && value.length > 0 ? value : null; -} - function base64(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); let binary = ""; @@ -237,114 +228,93 @@ export class RealtimeTranscriptionService extends Disposable { this.reportError("invalid_server_event", "session"); return; } - const event = objectValue(parsed); - const type = nonemptyString(event?.type); - if (event === null || type === null) { + const event = decodeRealtimeEvent(parsed); + if (event === null) { this.reportError("invalid_server_event", "session"); return; } - if (type === "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 ?? "", + 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, }, - turn_detection: null, }, + include: [HYPOTHESIS_INCLUDE], }, - include: [HYPOTHESIS_INCLUDE], - }, - event_id: (this.options.eventId ?? defaultEventId)(), - }); - return; - } - if (type === "session.updated") { - const session = objectValue(event.session); - const include = session?.include; - this.negotiatedHypotheses = - Array.isArray(include) && - include.length === 1 && - include[0] === HYPOTHESIS_INCLUDE; - this.reconnectDelayMs = RECONNECT_INITIAL_MS; - if (this.reconnectTimer !== null) { - clearTimeout(this.reconnectTimer); - this.reconnectTimer = null; - } - this.setState("ready"); - return; - } - if (type === "input_audio_buffer.committed") { - const itemId = nonemptyString(event.item_id); - if (itemId !== null) { - this.committedEmitter.fire(itemId); - } else { - this.reportError("invalid_server_event", "session"); - } - return; - } - if (type === "conversation.item.input_audio_transcription.hypothesis") { - const itemId = nonemptyString(event.item_id); - if (itemId !== null && typeof event.transcript === "string") { - this.snapshotEmitter.fire({ itemId, text: event.transcript }); - } else { - this.reportError("invalid_server_event", "session"); - } - return; - } - if (type === "conversation.item.input_audio_transcription.delta") { - if (this.negotiatedHypotheses) { + 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": + this.committedEmitter.fire(event.item_id); + return; + case "input_audio_buffer.cleared": + case "conversation.item.created": + return; + case "conversation.item.input_audio_transcription.hypothesis": + this.snapshotEmitter.fire({ + itemId: event.item_id, + text: event.transcript, + }); + return; + case "conversation.item.input_audio_transcription.delta": { + if (this.negotiatedHypotheses) { + return; + } + const text = (this.deltas.get(event.item_id) ?? "") + event.delta; + this.deltas.set(event.item_id, text); + this.snapshotEmitter.fire({ itemId: event.item_id, text }); return; } - const itemId = nonemptyString(event.item_id); - if (itemId !== null && typeof event.delta === "string") { - const text = (this.deltas.get(itemId) ?? "") + event.delta; - this.deltas.set(itemId, text); - this.snapshotEmitter.fire({ itemId, text }); - } else { - this.reportError("invalid_server_event", "session"); - } - return; - } - if (type === "conversation.item.input_audio_transcription.completed") { - const itemId = nonemptyString(event.item_id); - if (itemId !== null && typeof event.transcript === "string") { - this.deltas.delete(itemId); - this.completedEmitter.fire({ itemId, transcript: event.transcript }); - } else { - this.reportError("invalid_server_event", "session"); - } - return; - } - if (type === "conversation.item.input_audio_transcription.failed") { - const itemId = nonemptyString(event.item_id); - const error = objectValue(event.error); - if (itemId !== null && error !== null) { - this.deltas.delete(itemId); + case "conversation.item.input_audio_transcription.completed": + this.deltas.delete(event.item_id); + this.completedEmitter.fire({ + itemId: event.item_id, + transcript: event.transcript, + }); + return; + case "conversation.item.input_audio_transcription.failed": + this.deltas.delete(event.item_id); this.failedEmitter.fire({ - itemId, - code: nonemptyString(error.code) ?? "transcription_failed", + itemId: event.item_id, + code: event.error.code, }); - } else { - this.reportError("invalid_server_event", "session"); + return; + case "error": { + const eventId = event.error.event_id ?? null; + this.reportError( + event.error.code, + eventId === null ? "session" : "event", + eventId, + ); + return; + } + default: { + const exhaustive: never = event; + return exhaustive; } - return; - } - if (type === "error") { - const error = objectValue(event.error); - const eventId = nonemptyString(error?.event_id); - this.reportError( - nonemptyString(error?.code) ?? "server_error", - eventId === null ? "session" : "event", - eventId, - ); } } diff --git a/crates/workshop-server/ui/test/agent-stt.mjs b/crates/workshop-server/ui/test/agent-stt.mjs index c010c99a..46f92cf9 100644 --- a/crates/workshop-server/ui/test/agent-stt.mjs +++ b/crates/workshop-server/ui/test/agent-stt.mjs @@ -214,18 +214,18 @@ class FakeWebSocket { 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: [frame.committed, frame.tentative].filter(Boolean).join( - frame.committed && frame.tentative && !/\s$/.test(frame.committed) ? " " : "", - ), - finalized: frame.committed ?? "", + transcript: `${finalized}${tentative}`, + finalized, agreed: "", - tentative: frame.tentative ?? "", + tentative, audio_start_ms: 0, audio_end_ms: 100, }; diff --git a/crates/workshop-server/ui/test/helpers/boot.mjs b/crates/workshop-server/ui/test/helpers/boot.mjs index 529f678e..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; @@ -95,13 +109,7 @@ export async function bootWorkbench(name, run) { data: JSON.stringify({ type: "session.created", event_id: "boot_realtime_created", - session: { - id: "boot_realtime", - object: "realtime.transcription_session", - type: "transcription", - include: [], - audio: { input: {} }, - }, + session: realtimeSession([]), }), }); } @@ -121,13 +129,10 @@ export async function bootWorkbench(name, run) { data: JSON.stringify({ type: "session.updated", event_id: "boot_realtime_updated", - session: { - id: "boot_realtime", - object: "realtime.transcription_session", - type: "transcription", - include: ["item.input_audio_transcription.hypothesis"], - audio: { input: {} }, - }, + session: realtimeSession( + ["item.input_audio_transcription.hypothesis"], + event.session.audio.input.transcription.prompt, + ), }), }), ); diff --git a/crates/workshop-server/ui/test/realtime-wire-fixtures.mjs b/crates/workshop-server/ui/test/realtime-wire-fixtures.mjs index 0c696407..f790c431 100644 --- a/crates/workshop-server/ui/test/realtime-wire-fixtures.mjs +++ b/crates/workshop-server/ui/test/realtime-wire-fixtures.mjs @@ -3,8 +3,21 @@ 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, "..", @@ -125,6 +138,50 @@ function assertNonemptyString(value, context) { 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`); @@ -337,6 +394,114 @@ test("canonical Realtime event fixtures match the Rust case list unchanged", asy 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); @@ -353,6 +518,11 @@ test("canonical Realtime sequences cover every frozen contract path", async () = } 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); diff --git a/crates/workshop-server/ui/test/stt-stream.mjs b/crates/workshop-server/ui/test/stt-stream.mjs index 0c1ca8a3..967b9bbf 100644 --- a/crates/workshop-server/ui/test/stt-stream.mjs +++ b/crates/workshop-server/ui/test/stt-stream.mjs @@ -145,6 +145,187 @@ await assertNoLeaks(lifecycle, async () => { 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 committed = []; + const completions = []; + const errors = []; + service.onCommitted((value) => committed.push(value)); + service.onCompleted((value) => completions.push(value)); + service.onError((value) => errors.push(value)); + + sockets[0].open(); + sockets[0].message(server.session_created); + sockets[0].message(server.session_updated); + 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(committed, []); + assert.deepEqual(completions, []); + assert.deepEqual( + errors, + Array.from({ length: 3 }, () => ({ + code: "invalid_server_event", + scope: "session", + eventId: null, + recoverable: true, + })), + ); + service.dispose(); +}); + +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 snapshots = []; + const completions = []; + const errors = []; + service.onSnapshot((value) => snapshots.push(value)); + service.onCompleted((value) => completions.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(snapshots, [ + { itemId: "item_shared", text: "stale" }, + { itemId: "item_shared", text: "stale prefix" }, + { itemId: "item_shared", text: "fresh" }, + { itemId: "item_isolated", text: "other" }, + { itemId: "item_shared", text: "fresh transcript" }, + { itemId: "item_isolated", text: "other item" }, + { itemId: "item_isolated", text: "hypothesis wins" }, + ]); + assert.deepEqual(completions, [ + { itemId: "item_shared", transcript: "fresh transcript" }, + { itemId: "item_isolated", transcript: "hypothesis wins" }, + ]); + 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 { diff --git a/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index a9edbecd..912c12ac 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -428,7 +428,7 @@ isProject: false - 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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index d824ae84..500143f3 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -178,9 +178,9 @@ N49 | observation | oversized-unit @ crates/workshop-server/tests/it/realtime_re 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 +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 +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 @@ -200,3 +200,5 @@ N71 | observation | oversized-unit @ tools/check-stt-architecture.mjs::maskRustC 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 From 87775d6a4403b7126e2677eb61764d85db0136eb Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 16:34:51 -0700 Subject: [PATCH 74/86] Move insertion context into STT targets Capture each take's immutable insertion anchor, rollback text, and separator inside its input target after microphone startup succeeds. This aligns editable startup races with the state that becomes locked while preserving insertion and rollback behavior across both editor representations. - `SttInsertionContext` makes the selected range, original text, and composition prefix readonly. `SttInputTarget.insertionContext` replaces separate selection, document-end, and range-reading operations so textarea and ProseMirror targets own whitespace policy. - `setupStt` captures the context after `capture.start` succeeds and directly before it registers the take and locks the input. Delayed-edit tests prove both targets insert at the post-start selection, restore that edited text on rollback, and retain the captured separator even if content changes. - `crates/workshop-server/ui/src/ui/realtime-stt.ts` retains the current take lifecycle. This change adds no registry and changes no styles, markup, or visual behavior. Violates: A96 - bounded third-party model content in setupStt is not determinable from diff Pending: N59 - compounds Plan: vibe/2026-09-07-1-promptforge-debt.md --- .../workshop-server/ui/src/ui/prompt-input.ts | 30 ++-- .../workshop-server/ui/src/ui/realtime-stt.ts | 15 +- crates/workshop-server/ui/src/ui/stt.ts | 39 +++-- crates/workshop-server/ui/test/agent-stt.mjs | 66 +++++++- .../workshop-server/ui/test/prompt-input.mjs | 49 +++++- crates/workshop-server/ui/test/stt-stream.mjs | 146 +++++++++++++++++- vibe/2026-09-07-1-promptforge-debt.md | 2 +- vibe/archdoc-next.md | 2 +- 8 files changed, 302 insertions(+), 47 deletions(-) diff --git a/crates/workshop-server/ui/src/ui/prompt-input.ts b/crates/workshop-server/ui/src/ui/prompt-input.ts index 89e48cb1..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,15 +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 }; - } - - /** The logical document end in ProseMirror's position space. */ - getDocumentEnd(): number { - return this.editor.state.doc.content.size - 1; + 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. */ @@ -255,11 +260,6 @@ export class PromptInput extends Disposable implements SttInputTarget { .run(); } - /** Reads plain text from one ProseMirror range for reversible dictation. */ - readRange(from: number, to: number): string { - return this.editor.state.doc.textBetween(from, to, "\n", "\n"); - } - /** * The dictation take's lock: non-editable plus the recording ring on * the frame (stt.css's `.stt-input--recording`). Composes with the diff --git a/crates/workshop-server/ui/src/ui/realtime-stt.ts b/crates/workshop-server/ui/src/ui/realtime-stt.ts index 29333e0e..7183ae67 100644 --- a/crates/workshop-server/ui/src/ui/realtime-stt.ts +++ b/crates/workshop-server/ui/src/ui/realtime-stt.ts @@ -299,7 +299,6 @@ export function setupStt( status.showLocal("Dictation is connecting. Try again in a moment.", "info"); return; } - const selection = input.getSelection(); const outcome = await capture.start(); if (!outcome.ok) { status.showLocal(captureFailureLabel(outcome), "error"); @@ -309,16 +308,12 @@ export function setupStt( void releaseCapture(); return; } + const insertion = input.insertionContext(); const take: Take = { - from: selection.start, - length: selection.end - selection.start, - original: input.readRange(selection.start, selection.end), - compositionPrefix: - selection.start === selection.end && - selection.end === input.getDocumentEnd() && - /\S$/.test(input.readRange(0, selection.start)) - ? " " - : "", + from: insertion.range.start, + length: insertion.range.end - insertion.range.start, + original: insertion.original, + compositionPrefix: insertion.compositionPrefix, itemId: null, }; takes.push(take); diff --git a/crates/workshop-server/ui/src/ui/stt.ts b/crates/workshop-server/ui/src/ui/stt.ts index b31b3d83..3abde53a 100644 --- a/crates/workshop-server/ui/src/ui/stt.ts +++ b/crates/workshop-server/ui/src/ui/stt.ts @@ -5,6 +5,19 @@ import "./stt.css"; 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 @@ -14,14 +27,10 @@ export { setupStt } from "./realtime-stt"; * both spaces. */ export interface SttInputTarget { - /** The current selection: the take's insertion anchor. */ - getSelection(): { start: number; end: number }; - /** The logical document-end position in the target's coordinate space. */ - getDocumentEnd(): 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; - /** Reads the plain text currently occupying [from, to]. */ - readRange(from: number, to: number): string; /** Locks the input against typing while a take splices, or releases it. */ setReadOnly(readOnly: boolean): void; /** Returns focus to the input; a landed final calls it. */ @@ -39,11 +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, - }), - getDocumentEnd: () => 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, @@ -51,7 +67,6 @@ export function textareaSttTarget(input: HTMLTextAreaElement): SttInputTarget { // behaves like typing to whatever listens on the input. input.dispatchEvent(new Event("input", { bubbles: true })); }, - readRange: (from, to) => input.value.slice(from, to), setReadOnly: (readOnly) => { input.readOnly = readOnly; input.classList.toggle("stt-input--recording", readOnly); diff --git a/crates/workshop-server/ui/test/agent-stt.mjs b/crates/workshop-server/ui/test/agent-stt.mjs index 46f92cf9..ffeec07c 100644 --- a/crates/workshop-server/ui/test/agent-stt.mjs +++ b/crates/workshop-server/ui/test/agent-stt.mjs @@ -121,9 +121,39 @@ 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; @@ -868,6 +898,37 @@ await assertNoLeaks(lifecycle, async () => { 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 ------------------------------------------- { @@ -884,9 +945,10 @@ await assertNoLeaks(lifecycle, async () => { } 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()); diff --git a/crates/workshop-server/ui/test/prompt-input.mjs b/crates/workshop-server/ui/test/prompt-input.mjs index 9df3789e..4eb31859 100644 --- a/crates/workshop-server/ui/test/prompt-input.mjs +++ b/crates/workshop-server/ui/test/prompt-input.mjs @@ -283,17 +283,21 @@ await assertNoLeaks(lifecycle, () => { const input = new PromptInput(); input.setText("ab"); check("setText loads plain text", input.getText() === "ab"); - check("readRange preserves the text a take may need to restore", input.readRange(1, 3) === "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() === ""); @@ -306,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 --------------------------------------------- { @@ -326,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/stt-stream.mjs b/crates/workshop-server/ui/test/stt-stream.mjs index 967b9bbf..0fb105fc 100644 --- a/crates/workshop-server/ui/test/stt-stream.mjs +++ b/crates/workshop-server/ui/test/stt-stream.mjs @@ -6,6 +6,7 @@ import { mock } from "node:test"; import path from "node:path"; 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)); @@ -15,6 +16,8 @@ const bundle = await esbuild.build({ 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, ".."), loader: "ts", @@ -28,13 +31,71 @@ const bundle = await esbuild.build({ logLevel: "silent", }); -const { lifecycle, RealtimeTranscriptionService } = await import( +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 dom = new JSDOM(""); + const textarea = dom.window.document.querySelector("textarea"); + const target = textareaSttTarget(textarea); + + 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", + ); +} + class ScriptedSocket { static CONNECTING = 0; static OPEN = 1; @@ -75,6 +136,89 @@ class ScriptedSocket { } } +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(); + } +}); + await assertNoLeaks(lifecycle, async () => { const sockets = []; const service = new RealtimeTranscriptionService({ diff --git a/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index 912c12ac..1bfa51ee 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -438,7 +438,7 @@ isProject: false - 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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 500143f3..6cefa869 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -184,7 +184,7 @@ N55 | observation | dispatch-on-tag @ crates/workshop-server/ui/src/services/rea 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 +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 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 From 9901b97f2dded578f3f64d07aca24b35e7aa1b6c Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 16:53:06 -0700 Subject: [PATCH 75/86] Add the pure TakeRegistry transition reducer Model each dictation take as immutable state reduced from typed user, capture, wire, connection, and decoded server inputs. Return typed editor, capture, status, and wire effects so transitions perform no external work. Leave production integration unchanged. - `reduceTakeRegistry` clones all registry collections before each transition and returns the next state with its effects. - `take-registry.ts` splits reduction, event handling, state helpers, and type definitions into an acyclic four-module graph whose files each remain below 500 lines. - `RegistryTake` preserves both captured range endpoints, shifts later owned ranges by the exact replacement delta, and keeps completion text authoritative. - `captureStopped` releases only the take named by `stoppingTakeId`, while request identifiers correlate wire results and server errors with their owner. - `CommitExpectation` retains FIFO acknowledgment tombstones so discarded or duplicate commits cannot claim a later take. - `take-registry.mjs` covers overlap, rollback, reconnect, spacing, completion authority, selection replacement, typed correlation, and immutable-state invariants. `take-registry-regressions.mjs` pins captured range width, tombstone consumption, and stale or duplicate capture completion. - `take-registry.ts` has no production consumer in this change. Only the new tests import its reducer entry points. Design: new pure-function @ crates/workshop-server/ui/src/ui/take-registry.ts::createTakeRegistry Design: new pure-function @ crates/workshop-server/ui/src/ui/take-registry.ts::reduceTakeRegistry deps: TakeRegistry,TakeRegistryInput Design: new dispatch-on-tag @ crates/workshop-server/ui/src/ui/take-registry.ts::reduceTakeRegistry deps: TakeRegistry,TakeRegistryInput Design: new pure-function @ crates/workshop-server/ui/src/ui/take-registry-state.ts::cloneRegistry deps: TakeRegistry Design: new pure-function @ crates/workshop-server/ui/src/ui/take-registry-state.ts::composeTranscript deps: RegistryTake,string Design: new dispatch-on-tag @ crates/workshop-server/ui/src/ui/take-registry-events.ts::serverEvent deps: RealtimeEvent,Reduction Deferred: production callbacks do not consume TakeRegistry Plan: vibe/2026-09-07-1-promptforge-debt.md --- crates/workshop-server/ui/src/ui/stt.ts | 6 +- .../ui/src/ui/take-registry-events.ts | 280 +++++++++++ .../ui/src/ui/take-registry-state.ts | 177 +++++++ .../ui/src/ui/take-registry-types.ts | 158 ++++++ .../ui/src/ui/take-registry.ts | 268 ++++++++++ .../ui/test/take-registry-regressions.mjs | 225 +++++++++ .../workshop-server/ui/test/take-registry.mjs | 474 ++++++++++++++++++ vibe/2026-09-07-1-promptforge-debt.md | 2 +- 8 files changed, 1586 insertions(+), 4 deletions(-) create mode 100644 crates/workshop-server/ui/src/ui/take-registry-events.ts create mode 100644 crates/workshop-server/ui/src/ui/take-registry-state.ts create mode 100644 crates/workshop-server/ui/src/ui/take-registry-types.ts create mode 100644 crates/workshop-server/ui/src/ui/take-registry.ts create mode 100644 crates/workshop-server/ui/test/take-registry-regressions.mjs create mode 100644 crates/workshop-server/ui/test/take-registry.mjs diff --git a/crates/workshop-server/ui/src/ui/stt.ts b/crates/workshop-server/ui/src/ui/stt.ts index 3abde53a..7806db60 100644 --- a/crates/workshop-server/ui/src/ui/stt.ts +++ b/crates/workshop-server/ui/src/ui/stt.ts @@ -22,9 +22,9 @@ export interface SttInsertionContext { * 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 { /** Captures the selected range, rollback text, and target-owned insertion policy. */ 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..c8f26ec2 --- /dev/null +++ b/crates/workshop-server/ui/src/ui/take-registry-events.ts @@ -0,0 +1,280 @@ +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": + applyServerError(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", + }, + ); + } +} + +function applyServerError(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 }, + { domain: "wire", command: "clear" }, + ); + 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..bbdbc82b --- /dev/null +++ b/crates/workshop-server/ui/src/ui/take-registry-types.ts @@ -0,0 +1,158 @@ +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: "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..150f866d --- /dev/null +++ b/crates/workshop-server/ui/src/ui/take-registry.ts @@ -0,0 +1,268 @@ +import type { SttInsertionContext } from "./stt"; +import { + connectionLost, + failTake, + serverEvent, +} 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 "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 !== "recording") { + 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/test/take-registry-regressions.mjs b/crates/workshop-server/ui/test/take-registry-regressions.mjs new file mode 100644 index 00000000..98b68138 --- /dev/null +++ b/crates/workshop-server/ui/test/take-registry-regressions.mjs @@ -0,0 +1,225 @@ +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); +}); 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/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index 1bfa51ee..d80f8ba2 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -448,7 +448,7 @@ isProject: false - 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 +### 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. From 6ef4ee5744258226e0a215f8daf28d8d375fcbe2 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 17:15:51 -0700 Subject: [PATCH 76/86] Route production STT through TakeRegistry Give the pure reducer exclusive ownership of each dictation take and limit production wiring to typed effect interpretation. Publish each strictly decoded server event once, remove service-side delta accumulation and callback-owned lifecycle state, and handle decoded errors through one reducer path. Preserve reconnect recovery and wait for capture stop and flushed audio before commit. Production coverage pins capture, wire, status, rollback, spacing, and editor behavior. - `setupStt` keeps only `registry` as take state and routes service, capture, and user inputs through `reduceTakeRegistry`. `interpretEffect` exhaustively executes editor, capture, status, and wire effects in place of local maps, sets, flags, and offsets. - `RealtimeTranscriptionService` publishes one `onEvent` stream and removes `onCommitted`, `onSnapshot`, `onCompleted`, `onFailed`, and `deltas`. Decoded error events reach the reducer once, while `onError` is limited to connection and decoding failures. - `connectionLost` does not emit a wire clear after socket loss. `appendAudio` accepts capture chunks while stopping, so `capture.stopped` commits only after flushed audio is sent. - `stt-stream.mjs` covers callback removal, strict event delivery, one decoded error path, and reconnect shutdown without a stale clear. `take-registry-regressions.mjs` pins stop-flush ownership and commit ordering. Design: removes parallel-abstraction @ crates/workshop-server/ui/src/ui/realtime-stt.ts::Take Design: removes shared-mutable-state @ crates/workshop-server/ui/src/ui/realtime-stt.ts::setupStt::retiredItems Design: new dispatch-on-tag @ crates/workshop-server/ui/src/ui/realtime-stt.ts::setupStt::interpretEffect deps: TakeRegistryEffect Design: new oversized-unit @ crates/workshop-server/ui/src/ui/realtime-stt.ts::setupStt::interpretEffect deps: TakeRegistryEffect Design: extends pure-function @ crates/workshop-server/ui/src/ui/take-registry.ts::reduceTakeRegistry deps: TakeRegistry,TakeRegistryInput Design: extends dispatch-on-tag @ crates/workshop-server/ui/src/ui/take-registry.ts::reduceTakeRegistry deps: TakeRegistry,TakeRegistryInput Violates: A96 - bounded third-party model content in setupStt is not determinable from diff Pending: N59 - compounds Plan: vibe/2026-09-07-1-promptforge-debt.md --- .../ui/src/services/realtime-transcription.ts | 97 +---- .../workshop-server/ui/src/ui/realtime-stt.ts | 382 ++++++------------ .../ui/src/ui/take-registry-events.ts | 9 +- .../ui/src/ui/take-registry-types.ts | 1 + .../ui/src/ui/take-registry.ts | 6 +- crates/workshop-server/ui/test/stt-stream.mjs | 289 +++++++++++-- .../ui/test/take-registry-regressions.mjs | 32 ++ vibe/2026-09-07-1-promptforge-debt.md | 2 +- vibe/archdoc-next.md | 2 +- 9 files changed, 439 insertions(+), 381 deletions(-) diff --git a/crates/workshop-server/ui/src/services/realtime-transcription.ts b/crates/workshop-server/ui/src/services/realtime-transcription.ts index 89312e57..1947ef54 100644 --- a/crates/workshop-server/ui/src/services/realtime-transcription.ts +++ b/crates/workshop-server/ui/src/services/realtime-transcription.ts @@ -1,6 +1,9 @@ import { Emitter, type Event as ServiceEvent } from "../base/event"; import { Disposable } from "../base/lifecycle"; -import { decodeRealtimeEvent } from "./realtime-event-decoder"; +import { + decodeRealtimeEvent, + type RealtimeEvent, +} from "./realtime-event-decoder"; const HYPOTHESIS_INCLUDE = "item.input_audio_transcription.hypothesis"; const RECONNECT_INITIAL_MS = 1000; @@ -9,28 +12,10 @@ const RECONNECT_MAX_MS = 30_000; /** Readiness of the browser's Realtime transcription connection. */ export type RealtimeTranscriptionState = "connecting" | "ready" | "unavailable"; -/** A complete replacement snapshot for one committed audio item. */ -export interface RealtimeTranscriptSnapshot { - readonly itemId: string; - readonly text: string; -} - -/** The authoritative transcript for one committed audio item. */ -export interface RealtimeTranscriptCompletion { - readonly itemId: string; - readonly transcript: string; -} - -/** A recoverable terminal failure for one committed audio item. */ -export interface RealtimeTranscriptFailure { - readonly itemId: string; - readonly code: string; -} - -/** A recoverable connection, session, or client-event failure. */ +/** A recoverable transport or server-event decoding failure. */ export interface RealtimeTranscriptionError { readonly code: string; - readonly scope: "connection" | "session" | "event"; + readonly scope: "connection" | "session"; readonly eventId: string | null; readonly recoverable: true; } @@ -84,19 +69,12 @@ function base64(buffer: ArrayBuffer): string { /** * Owns one OpenAI-compatible Realtime transcription socket. It sends only - * canonical client events and exposes item-keyed replacement snapshots so - * views never need to interpret wire deltas or server-authored status text. + * canonical client events and publishes only strictly decoded server events. */ export class RealtimeTranscriptionService extends Disposable { private readonly stateEmitter = this._register(new Emitter()); - private readonly committedEmitter = this._register(new Emitter()); - private readonly snapshotEmitter = this._register(new Emitter()); - private readonly completedEmitter = this._register( - new Emitter(), - ); - private readonly failedEmitter = this._register(new Emitter()); + private readonly eventEmitter = this._register(new Emitter()); private readonly errorEmitter = this._register(new Emitter()); - private readonly deltas = new Map(); private socket: RealtimeSocket | null = null; private disposed = false; private negotiatedHypotheses = false; @@ -106,15 +84,9 @@ export class RealtimeTranscriptionService extends Disposable { /** Fires when connection readiness changes. */ readonly onState: ServiceEvent = this.stateEmitter.event; - /** Fires when the server assigns an item ID to the oldest committed take. */ - readonly onCommitted: ServiceEvent = this.committedEmitter.event; - /** Fires complete replacement text for one item. */ - readonly onSnapshot: ServiceEvent = this.snapshotEmitter.event; - /** Fires the authoritative completion for one item. */ - readonly onCompleted: ServiceEvent = this.completedEmitter.event; - /** Fires a recoverable item-scoped failure. */ - readonly onFailed: ServiceEvent = this.failedEmitter.event; - /** Fires a recoverable connection, protocol, or unscoped failure. */ + /** 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 = {}) { @@ -212,7 +184,6 @@ export class RealtimeTranscriptionService extends Disposable { const socket = this.socket; this.socket = null; socket?.close(); - this.deltas.clear(); super.dispose(); } @@ -233,6 +204,13 @@ export class RealtimeTranscriptionService extends Disposable { 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": @@ -268,49 +246,15 @@ export class RealtimeTranscriptionService extends Disposable { this.setState("ready"); return; case "input_audio_buffer.committed": - this.committedEmitter.fire(event.item_id); - return; case "input_audio_buffer.cleared": case "conversation.item.created": - return; case "conversation.item.input_audio_transcription.hypothesis": - this.snapshotEmitter.fire({ - itemId: event.item_id, - text: event.transcript, - }); - return; - case "conversation.item.input_audio_transcription.delta": { - if (this.negotiatedHypotheses) { - return; - } - const text = (this.deltas.get(event.item_id) ?? "") + event.delta; - this.deltas.set(event.item_id, text); - this.snapshotEmitter.fire({ itemId: event.item_id, text }); - return; - } + case "conversation.item.input_audio_transcription.delta": case "conversation.item.input_audio_transcription.completed": - this.deltas.delete(event.item_id); - this.completedEmitter.fire({ - itemId: event.item_id, - transcript: event.transcript, - }); - return; case "conversation.item.input_audio_transcription.failed": - this.deltas.delete(event.item_id); - this.failedEmitter.fire({ - itemId: event.item_id, - code: event.error.code, - }); return; - case "error": { - const eventId = event.error.event_id ?? null; - this.reportError( - event.error.code, - eventId === null ? "session" : "event", - eventId, - ); + case "error": return; - } default: { const exhaustive: never = event; return exhaustive; @@ -348,7 +292,6 @@ export class RealtimeTranscriptionService extends Disposable { private resetConnectionState(): void { this.negotiatedHypotheses = false; - this.deltas.clear(); } private scheduleReconnect(): void { diff --git a/crates/workshop-server/ui/src/ui/realtime-stt.ts b/crates/workshop-server/ui/src/ui/realtime-stt.ts index 7183ae67..44db3be2 100644 --- a/crates/workshop-server/ui/src/ui/realtime-stt.ts +++ b/crates/workshop-server/ui/src/ui/realtime-stt.ts @@ -1,9 +1,5 @@ import { DisposableStore, toDisposable } from "../base/lifecycle"; -import { - RealtimeTranscriptionService, - type RealtimeTranscriptCompletion, - type RealtimeTranscriptSnapshot, -} from "../services/realtime-transcription"; +import { RealtimeTranscriptionService } from "../services/realtime-transcription"; import { SpeechCaptureService, type SpeechCaptureFailure, @@ -15,14 +11,13 @@ import type { SttHandle, SttStatus, } from "./stt"; - -interface Take { - from: number; - length: number; - readonly original: string; - readonly compositionPrefix: "" | " "; - itemId: string | null; -} +import { + createTakeRegistry, + reduceTakeRegistry, + type TakeRegistry, + type TakeRegistryEffect, + type TakeRegistryInput, +} from "./take-registry"; function captureFailureLabel(failure: SpeechCaptureFailure): string { if (failure.kind === "permission-denied") { @@ -39,7 +34,8 @@ function captureFailureLabel(failure: SpeechCaptureFailure): string { /** * Wires push-to-talk UI to production PCM16 capture and the additive Realtime - * relay. Item-keyed take regions isolate overlapping authoritative results. + * relay. The registry exclusively owns take state; this layer interprets its + * typed editor, capture, status, and wire effects. */ export function setupStt( elements: SttElements, @@ -51,13 +47,7 @@ export function setupStt( const { mic, input } = elements; const store = new DisposableStore(); const realtime = providedRealtime ?? store.add(new RealtimeTranscriptionService()); - const takes: Take[] = []; - const awaitingCommit: Array = []; - const byItem = new Map(); - const byClientEvent = new Map(); - const retiredItems = new Set(); - let active: Take | null = null; - let stopping = false; + let registry: TakeRegistry = createTakeRegistry(); let pendingCaptureStop: Promise | null = null; let disposed = false; @@ -65,7 +55,7 @@ export function setupStt( mic.classList.toggle("stt-mic--recording", recording); mic.setAttribute("aria-pressed", String(recording)); mic.title = recording ? "Stop recording" : "Push to talk"; - status.setRecording(recording || (active === null && capture.recording)); + status.setRecording(recording); } function releaseCapture(): Promise { @@ -82,203 +72,123 @@ export function setupStt( return stoppingCapture; } - function syncInputLock(): void { - input.setReadOnly(takes.length > 0); - } - - function composeTranscript(take: Take, transcript: string): string { - return take.compositionPrefix !== "" && - transcript !== "" && - !/^\s/.test(transcript) - ? take.compositionPrefix + transcript - : transcript; - } - - function splice(take: Take, text: string): void { - const oldEnd = take.from + take.length; - const delta = text.length - take.length; - input.replaceRange(take.from, oldEnd, text); - take.length = text.length; - if (delta === 0) { - return; - } - for (const other of takes) { - if (other !== take && other.from >= oldEnd) { - other.from += delta; - } - } - } - - function removeTake(take: Take): void { - const index = takes.indexOf(take); - if (index >= 0) { - takes.splice(index, 1); - } - const waiting = awaitingCommit.indexOf(take); - if (waiting >= 0) { - awaitingCommit[waiting] = null; - } - if (take.itemId !== null) { - byItem.delete(take.itemId); - retiredItems.add(take.itemId); - } - if (active === take) { - active = null; - } - for (const [eventId, owner] of byClientEvent) { - if (owner === take) { - byClientEvent.delete(eventId); + 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; } } - syncInputLock(); } - function rollback(take: Take): void { - splice(take, take.original); - removeTake(take); - } - - function rollbackAll(): void { - for (const take of [...takes].reverse()) { - rollback(take); - } - } - - function takeFor(itemId: string): Take | null { - return byItem.get(itemId) ?? null; - } - - function applySnapshot(snapshot: RealtimeTranscriptSnapshot): void { - let take = takeFor(snapshot.itemId); - if (take === null) { - if (retiredItems.has(snapshot.itemId) || awaitingCommit.includes(null)) { - return; - } - const unbound = takes.filter((candidate) => candidate.itemId === null); - if (unbound.length !== 1) { - return; - } - take = unbound[0]; - take.itemId = snapshot.itemId; - byItem.set(snapshot.itemId, take); - } - splice(take, composeTranscript(take, snapshot.text)); - } - - function applyCompletion(completion: RealtimeTranscriptCompletion): void { - const take = takeFor(completion.itemId); - if (take === null) { - return; - } - if (active === take && capture.recording) { - active = null; - void releaseCapture(); - setRecording(false); - } - const authoritative = completion.transcript.trimEnd(); - const transcript = composeTranscript(take, authoritative); - splice(take, transcript); - removeTake(take); - if (transcript === "") { - status.showLocal("No speech was detected.", "info"); - } else { - input.focus(); - status.showLocal("Dictation ready.", "info"); + function dispatch(inputEvent: TakeRegistryInput): void { + const transition = reduceTakeRegistry(registry, inputEvent); + registry = transition.state; + for (const effect of transition.effects) { + interpretEffect(effect); } } store.add( - realtime.onCommitted((itemId) => { - if (awaitingCommit.length === 0) { - if (byItem.has(itemId)) { - return; - } - if (active !== null && active.itemId === null) { - active.itemId = itemId; - byItem.set(itemId, active); - return; - } - retiredItems.add(itemId); - status.showLocal("Dictation is temporarily unavailable. Try again.", "error"); - return; - } - const take = awaitingCommit[0]; - if (take === null) { - awaitingCommit.shift(); - retiredItems.add(itemId); - return; - } - if (take.itemId === null) { - awaitingCommit.shift(); - take.itemId = itemId; - byItem.set(itemId, take); - return; - } - if (take.itemId === itemId) { - awaitingCommit.shift(); - return; - } - awaitingCommit.shift(); - retiredItems.add(itemId); - rollback(take); - status.showLocal("Dictation is temporarily unavailable. Try again.", "error"); + realtime.onEvent((event) => { + dispatch({ type: "server.event", event }); }), ); - store.add(realtime.onSnapshot(applySnapshot)); - store.add(realtime.onCompleted(applyCompletion)); store.add( - realtime.onFailed(({ itemId }) => { - const take = takeFor(itemId); - if (take !== null) { - rollback(take); + realtime.onState((state) => { + if (state === "ready") { + dispatch({ type: "connection.ready" }); } - status.showLocal("Dictation could not be transcribed. Try again.", "error"); }), ); store.add( realtime.onError((error) => { if (error.scope === "connection") { - if (takes.length > 0 && active !== null && capture.recording) { - capture.clear(); - void releaseCapture(); - } - rollbackAll(); - awaitingCommit.length = 0; - setRecording(false); - } else { - const affected = - error.scope === "event" && error.eventId !== null - ? byClientEvent.get(error.eventId) ?? null - : active; - if (affected !== null) { - if (active === affected && capture.recording) { - capture.clear(); - void releaseCapture(); - } - rollback(affected); - setRecording(false); - } + dispatch({ type: "connection.lost" }); + } else if (error.scope === "session") { + dispatch({ type: "service.error", eventId: error.eventId }); } - status.showLocal("Dictation is temporarily unavailable. Try again.", "error"); }), ); store.add( capture.onAudio((chunk) => { - const take = active; - if (take === null) { - return; - } - const eventId = realtime.append(chunk); - if (eventId === null) { - capture.clear(); - void releaseCapture(); - if (takes.includes(take)) { - rollback(take); - } - setRecording(false); - } else { - byClientEvent.set(eventId, take); - } + dispatch({ type: "capture.audio", chunk }); }), ); @@ -290,7 +200,11 @@ export function setupStt( } if (pendingCaptureStop !== null) { await pendingCaptureStop; - if (disposed || active !== null) { + if ( + disposed || + registry.activeTakeId !== null || + registry.capture !== "idle" + ) { return; } } @@ -308,74 +222,26 @@ export function setupStt( void releaseCapture(); return; } - const insertion = input.insertionContext(); - const take: Take = { - from: insertion.range.start, - length: insertion.range.end - insertion.range.start, - original: insertion.original, - compositionPrefix: insertion.compositionPrefix, - itemId: null, - }; - takes.push(take); - active = take; - syncInputLock(); - setRecording(true); - status.showLocal("Listening...", "info"); + dispatch({ type: "user.start", context: input.insertionContext() }); + if ( + registry.activeTakeId === null || + registry.capture !== "recording" + ) { + void releaseCapture(); + } } - async function stop(): Promise { - const take = active; - if (take === null || stopping) { - return; - } - stopping = true; - const stoppingCapture = releaseCapture(); - setRecording(false); - status.showLocal("Transcribing...", "info"); - const outcome = await stoppingCapture; - if (active === take) { - active = null; - } - stopping = false; - if (disposed) { - return; - } - if (!takes.includes(take)) { - return; - } - if (!outcome.ok) { - realtime.clear(); - rollback(take); - status.showLocal(captureFailureLabel(outcome), "error"); - return; - } - const eventId = realtime.commit(); - if (eventId === null) { - rollback(take); - return; - } - awaitingCommit.push(take); - byClientEvent.set(eventId, take); + function stop(): void { + dispatch({ type: "user.stop" }); } function discardIfRecording(): void { - if (takes.length === 0) { - return; - } - if (active !== null && capture.recording) { - capture.clear(); - realtime.clear(); - void releaseCapture(); - } - active = null; - stopping = false; - rollbackAll(); - setRecording(false); + dispatch({ type: "user.discard" }); } const onMicClick = (): void => { - if (active !== null) { - void stop(); + if (registry.activeTakeId !== null) { + stop(); } else { void start(); } diff --git a/crates/workshop-server/ui/src/ui/take-registry-events.ts b/crates/workshop-server/ui/src/ui/take-registry-events.ts index c8f26ec2..6b8e02f4 100644 --- a/crates/workshop-server/ui/src/ui/take-registry-events.ts +++ b/crates/workshop-server/ui/src/ui/take-registry-events.ts @@ -54,7 +54,7 @@ export function serverEvent(reduction: Reduction, event: RealtimeEvent): void { return; } case "error": - applyServerError(reduction, event.error.event_id ?? null); + serviceError(reduction, event.error.event_id ?? null); return; default: { const exhaustive: never = event; @@ -210,7 +210,11 @@ function completeTake( } } -function applyServerError(reduction: Reduction, eventId: string | null): void { +/** 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 @@ -236,7 +240,6 @@ export function connectionLost(reduction: Reduction): void { 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; diff --git a/crates/workshop-server/ui/src/ui/take-registry-types.ts b/crates/workshop-server/ui/src/ui/take-registry-types.ts index bbdbc82b..fc9f7afd 100644 --- a/crates/workshop-server/ui/src/ui/take-registry-types.ts +++ b/crates/workshop-server/ui/src/ui/take-registry-types.ts @@ -64,6 +64,7 @@ export type TakeRegistryInput = 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" }; diff --git a/crates/workshop-server/ui/src/ui/take-registry.ts b/crates/workshop-server/ui/src/ui/take-registry.ts index 150f866d..91806aa2 100644 --- a/crates/workshop-server/ui/src/ui/take-registry.ts +++ b/crates/workshop-server/ui/src/ui/take-registry.ts @@ -3,6 +3,7 @@ import { connectionLost, failTake, serverEvent, + serviceError, } from "./take-registry-events"; import { activeTake, @@ -80,6 +81,9 @@ export function reduceTakeRegistry( case "server.event": serverEvent(reduction, input.event); break; + case "service.error": + serviceError(reduction, input.eventId); + break; case "connection.lost": connectionLost(reduction); break; @@ -158,7 +162,7 @@ function stopTake(reduction: Reduction): void { function appendAudio(reduction: Reduction, chunk: ArrayBuffer): void { const take = activeTake(reduction.state); - if (take === null || reduction.state.capture !== "recording") { + if (take === null || reduction.state.capture === "idle") { return; } const requestId = reserveWireRequest(reduction.state, "append", take.id); diff --git a/crates/workshop-server/ui/test/stt-stream.mjs b/crates/workshop-server/ui/test/stt-stream.mjs index 0fb105fc..9c2c50bf 100644 --- a/crates/workshop-server/ui/test/stt-stream.mjs +++ b/crates/workshop-server/ui/test/stt-stream.mjs @@ -219,6 +219,170 @@ await assertNoLeaks(lifecycle, async () => { } }); +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 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 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); + + 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(); + for ( + let turn = 0; + turn < 4 && trace.at(-1) !== "status.local:Listening..."; + turn++ + ) { + await Promise.resolve(); + } + trace.length = 0; + const sentBeforeLoss = structuredClone(socket.sent); + + socket.close(); + + 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({ @@ -239,15 +403,23 @@ await assertNoLeaks(lifecycle, async () => { }, }); const states = []; - const snapshots = []; - const completions = []; - const failures = []; + const events = []; const errors = []; service.onState((value) => states.push(value)); - service.onSnapshot((value) => snapshots.push(value)); - service.onCompleted((value) => completions.push(value)); - service.onFailed((value) => failures.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`, + ); + } assert.equal(sockets.length, 1); assert.match(sockets[0].url, /\/v1\/realtime$/); @@ -273,17 +445,24 @@ await assertNoLeaks(lifecycle, async () => { sockets[0].message(server.transcription_completed); sockets[0].message(server.transcription_failed); sockets[0].message(server.error_correlated); - assert.deepEqual(snapshots, [{ itemId: "item_alpha", text: "Hello, world" }]); - assert.deepEqual(completions, [{ itemId: "item_alpha", transcript: "Hello, world" }]); - assert.deepEqual(failures, [{ itemId: "item_beta", code: "transcription_failed" }]); - assert.deepEqual(errors, [ - { - code: "unsupported_model", - scope: "event", - eventId: "client_bad_update", - recoverable: true, - }, - ]); + 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", + ); + assert.deepEqual( + errors, + [], + "decoded server failures publish only through the reducer event seam", + ); service.dispose(); assert.equal(sockets[0].readyState, ScriptedSocket.CLOSED); @@ -299,16 +478,15 @@ await assertNoLeaks(lifecycle, async () => { return socket; }, }); - const committed = []; - const completions = []; + const events = []; const errors = []; - service.onCommitted((value) => committed.push(value)); - service.onCompleted((value) => completions.push(value)); + 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, @@ -322,8 +500,7 @@ await assertNoLeaks(lifecycle, async () => { type: "response.created", }); - assert.deepEqual(committed, []); - assert.deepEqual(completions, []); + assert.deepEqual(events, []); assert.deepEqual( errors, Array.from({ length: 3 }, () => ({ @@ -348,11 +525,9 @@ await assertNoLeaks(lifecycle, async () => { return socket; }, }); - const snapshots = []; - const completions = []; const errors = []; - service.onSnapshot((value) => snapshots.push(value)); - service.onCompleted((value) => completions.push(value)); + const events = []; + service.onEvent((value) => events.push(value)); service.onError((value) => errors.push(value)); const fallbackSessionUpdated = { @@ -444,19 +619,53 @@ await assertNoLeaks(lifecycle, async () => { transcript: "hypothesis wins", }); - assert.deepEqual(snapshots, [ - { itemId: "item_shared", text: "stale" }, - { itemId: "item_shared", text: "stale prefix" }, - { itemId: "item_shared", text: "fresh" }, - { itemId: "item_isolated", text: "other" }, - { itemId: "item_shared", text: "fresh transcript" }, - { itemId: "item_isolated", text: "other item" }, - { itemId: "item_isolated", text: "hypothesis wins" }, - ]); - assert.deepEqual(completions, [ - { itemId: "item_shared", transcript: "fresh transcript" }, - { itemId: "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", diff --git a/crates/workshop-server/ui/test/take-registry-regressions.mjs b/crates/workshop-server/ui/test/take-registry-regressions.mjs index 98b68138..05d3a889 100644 --- a/crates/workshop-server/ui/test/take-registry-regressions.mjs +++ b/crates/workshop-server/ui/test/take-registry-regressions.mjs @@ -223,3 +223,35 @@ test("a duplicate capture completion cannot recommit an older retained take", () 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/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index d80f8ba2..45eb5e25 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -458,7 +458,7 @@ isProject: false - 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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 6cefa869..76005bd0 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -184,7 +184,7 @@ N55 | observation | dispatch-on-tag @ crates/workshop-server/ui/src/services/rea 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 +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 From 5b9d2a5ae0e0b14b9fa6fdee57156239ab27019a Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 17:39:29 -0700 Subject: [PATCH 77/86] Discover Rust 1.89.0 for NetworkService runners Keep native CI on exact Rust 1.89.0 when the self-hosted runner uses NetworkService. Build a deduplicated list of bounded tool directories, then select one directory that contains both cargo.exe and rustc.exe before version checks and cache use. - PROMPTFORGE_RUST_1_89_0_BIN remains the first choice. Discovery then checks PATH, RUSTUP_HOME, USERPROFILE, NetworkService, and Cargo home locations. - Add-RustBinCandidate normalizes and deduplicates candidates without recursive scans. Failure output lists each checked source when no bounded directory has both tools. - Tests execute the NetworkService rustup path without PROMPTFORGE_RUST_1_89_0_BIN, check GITHUB_PATH, prohibit Get-ChildItem and -Recurse, and pin the aggregate failure report. --- .github/workflows/stt-miri.yml | 160 ++++++++++++++++--- tools/check-stt-native-workflow.test.mjs | 190 +++++++++++++++++++---- 2 files changed, 298 insertions(+), 52 deletions(-) diff --git a/.github/workflows/stt-miri.yml b/.github/workflows/stt-miri.yml index 4b175b5d..aafec973 100644 --- a/.github/workflows/stt-miri.yml +++ b/.github/workflows/stt-miri.yml @@ -59,6 +59,8 @@ jobs: $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() @@ -68,29 +70,147 @@ jobs: if (-not (Test-Path $contractBin -PathType Container)) { throw "self-hosted runner Rust $requiredVersion contract $contractName directory does not exist: '$contractBin'" } - $contractBin = (Resolve-Path $contractBin).Path + $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, - [string] $Bin + [Parameter(Mandatory = $true)][string] $Bin ) - if (-not [string]::IsNullOrWhiteSpace($Bin)) { - $candidate = Join-Path $Bin "$Name.exe" - if (-not (Test-Path $candidate -PathType Leaf)) { - throw "self-hosted runner Rust $requiredVersion contract $contractName is missing $Name.exe at '$candidate'" - } - return (Resolve-Path $candidate).Path - } - - $command = Get-Command "$Name.exe" -CommandType Application -ErrorAction SilentlyContinue | - Select-Object -First 1 - if (-not $command) { - throw "self-hosted runner Rust $requiredVersion is not provisioned: $Name.exe was not found on PATH; install it outside CI or set $contractName to its versioned bin directory" + $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 $command.Source + return (Resolve-Path $candidate).Path } function Assert-RustToolVersion { @@ -138,8 +258,8 @@ jobs: ) } - $cargo = Resolve-RustTool -Name 'cargo' -Bin $contractBin - $rustc = Resolve-RustTool -Name 'rustc' -Bin $contractBin + $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 @@ -150,7 +270,7 @@ jobs: 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 $contractBin + $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" @@ -160,9 +280,7 @@ jobs: Write-Host 'Using direct Rust tools' } - if (-not [string]::IsNullOrWhiteSpace($contractBin)) { - $contractBin | Add-Content -Path $env:GITHUB_PATH - } + $rustBin | Add-Content -Path $env:GITHUB_PATH - name: Cache Cargo uses: Swatinem/rust-cache@v2 diff --git a/tools/check-stt-native-workflow.test.mjs b/tools/check-stt-native-workflow.test.mjs index bcdee2db..5e43ad0e 100644 --- a/tools/check-stt-native-workflow.test.mjs +++ b/tools/check-stt-native-workflow.test.mjs @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { chmodSync, copyFileSync, + mkdirSync, mkdtempSync, readFileSync, rmSync, @@ -57,8 +58,11 @@ function stepScript(job, name) { .trimEnd(); } -function createToolLayout(names, { proxy = false } = {}) { - const bin = mkdtempSync(join(fixtureRoot, proxy ? "proxy-bin-" : "direct-bin-")); +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); @@ -67,27 +71,76 @@ function createToolLayout(names, { proxy = false } = {}) { return bin; } -function runPreflight({ bin, explicitBin }) { - const environment = { ...process.env }; - for (const key of Object.keys(environment)) { - if (key.toUpperCase() === "PROMPTFORGE_RUST_1_89_0_BIN") { - delete environment[key]; - } +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]; } - environment.GITHUB_PATH = join(fixtureRoot, "github-path"); - environment.RUSTUP_TOOLCHAIN = "1.89"; - environment.RUSTUP_AUTO_INSTALL = "0"; - if (explicitBin) { - environment.PROMPTFORGE_RUST_1_89_0_BIN = bin; +} + +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 { - const pathKey = - Object.keys(environment).find((key) => key.toLowerCase() === "path") ?? - "PATH"; - environment[pathKey] = `${bin}${delimiter}${environment[pathKey] ?? ""}`; + throw new Error(`unknown preflight discovery mode: ${mode}`); } - const executable = process.platform === "win32" ? "powershell.exe" : "pwsh"; - return spawnSync( + 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], { @@ -97,6 +150,7 @@ function runPreflight({ bin, explicitBin }) { timeout: 30_000, }, ); + return { ...result, githubPath }; } before(() => { @@ -152,10 +206,10 @@ test("native runner validates the exact repository MSRV before caching", () => { 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 $contractBin", + "$cargo = Resolve-RustTool -Name 'cargo' -Bin $rustBin", ); const resolveRustc = native.indexOf( - "$rustc = Resolve-RustTool -Name 'rustc' -Bin $contractBin", + "$rustc = Resolve-RustTool -Name 'rustc' -Bin $rustBin", ); const validateCargo = native.indexOf( "Assert-RustToolVersion -Name 'cargo' -ToolPath $cargo", @@ -164,7 +218,7 @@ test("native runner validates the exact repository MSRV before caching", () => { "Assert-RustToolVersion -Name 'rustc' -ToolPath $rustc", ); const publishContract = native.indexOf( - "$contractBin | Add-Content -Path $env:GITHUB_PATH", + "$rustBin | Add-Content -Path $env:GITHUB_PATH", ); assert.ok(preflight > 0, "native job must have a Rust preflight"); @@ -215,7 +269,41 @@ test("rustup-managed PATH proxies use the exact preinstalled toolchain", () => { assert.match(result.stdout, /Using rustup proxies from /); }); -test("tool discovery supports PATH and the versioned runner contract", () => { +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( @@ -224,15 +312,33 @@ test("tool discovery supports PATH and the versioned runner contract", () => { ); 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 "\$Name\.exe" -CommandType Application -ErrorAction SilentlyContinue/, + /Get-Command 'cargo\.exe' -CommandType Application -ErrorAction SilentlyContinue/, ); - assert.match(native, /return \$command\.Source/); - assert.match(native, /\$contractBin \| Add-Content -Path \$env:GITHUB_PATH/); - assert.doesNotMatch(native, /\$env:USERPROFILE/); - assert.doesNotMatch(native, /\.cargo\\bin/); + 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", () => { @@ -241,7 +347,7 @@ test("rustup proxies remain pinned and cannot auto-install", () => { "$cargoIsRustupProxy = Test-RustupProxy -ToolPath $cargo", ); const resolveRustup = native.indexOf( - "$rustup = Resolve-RustTool -Name 'rustup' -Bin $contractBin", + "$rustup = Resolve-RustTool -Name 'rustup' -Bin $rustBin", ); assert.match(native, /^\s+RUSTUP_TOOLCHAIN: 1\.89$/m); @@ -279,16 +385,38 @@ test("native preflight reports actionable missing-tool failures", () => { assert.match( native, - /contract \$contractName is missing \$Name\.exe at '\$candidate'/, + /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, - /\$Name\.exe was not found on PATH; install it outside CI or set \$contractName to its versioned bin directory/, + /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"); From dff3f155c330b0770e19d80b297d9c96af53fb41 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 17:40:49 -0700 Subject: [PATCH 78/86] Define pure agent supervisor transitions Define a pure reducer that maps explicit supervision events to immutable next state and one typed effect. It coalesces catalog and Gateway generations monotonically, scopes accepted-input settlement to the owning run, and makes stale or duplicate events preserve settled ownership. - `transition` keeps supervisor state, events, and effects inside the session-agent crate. `CancelOrigin` moves behind that boundary while the existing lifecycle module re-exports it. - `SupervisorState` defers catalog replacement during accepted input, preserves the newest applicable catalog and Gateway generations, and relaunches over retained history after one owned cancellation. - `SupervisorEvent` keys run completion, input acceptance, and terminal settlement by `RunId`; stale and duplicate notifications return preserve effects, and terminal close is emitted once. - `transition` remains unwired; `supervisor.rs` marks the module as expected dead code until effect execution lands separately. Design: new pure-function @ crates/workshop-server/src/session_agents/supervisor/transition.rs::transition deps: SupervisorEvent,SupervisorState Design: new newtype @ crates/workshop-server/src/session_agents/supervisor/transition.rs::RunId Design: new oversized-unit @ crates/workshop-server/src/session_agents/supervisor/transition.rs Design: new oversized-unit @ crates/workshop-server/src/session_agents/supervisor/transition/tests.rs Violates: A99 - SupervisorEffect descendant cancellation propagation and sibling isolation are not determinable from diff Deferred: async supervisor effect execution remains in the existing loop Plan: vibe/2026-09-07-1-promptforge-debt.md --- crates/workshop-server/module-ceilings.toml | 10 +- .../src/session_agents/lifecycle.rs | 11 +- .../src/session_agents/supervisor.rs | 4 +- .../session_agents/supervisor/transition.rs | 424 ++++++++++++++++++ .../supervisor/transition/tests.rs | 336 ++++++++++++++ vibe/2026-09-07-1-promptforge-debt.md | 2 +- vibe/archdoc-next.md | 3 + 7 files changed, 775 insertions(+), 15 deletions(-) create mode 100644 crates/workshop-server/src/session_agents/supervisor/transition.rs create mode 100644 crates/workshop-server/src/session_agents/supervisor/transition/tests.rs diff --git a/crates/workshop-server/module-ceilings.toml b/crates/workshop-server/module-ceilings.toml index 114fc8d1..0f2d9ffc 100644 --- a/crates/workshop-server/module-ceilings.toml +++ b/crates/workshop-server/module-ceilings.toml @@ -200,13 +200,19 @@ # Thinking LED, which only on_assistant_reply's idle otherwise clears) # and its pinning test. "session_agents.rs" = 1028 -# New module: cancellation provenance and accepted-turn settlement. -"session_agents/lifecycle.rs" = 102 +# Cancellation provenance moved into the pure supervisor transition model; +# this module retains the shared handle and accepted-turn notification. +"session_agents/lifecycle.rs" = 95 # New module: one agent session's run lifecycle across turn cancellation, # delayed catalog readiness, and usable chat-catalog replacement. "session_agents/supervisor.rs" = 170 # Catalog-generation waits split from agent run orchestration. "session_agents/supervisor/catalog.rs" = 53 +# New pure supervisor event reducer. The async loop remains unchanged until +# its separate wiring step. +"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/session_agents/lifecycle.rs b/crates/workshop-server/src/session_agents/lifecycle.rs index a479843c..988f4b16 100644 --- a/crates/workshop-server/src/session_agents/lifecycle.rs +++ b/crates/workshop-server/src/session_agents/lifecycle.rs @@ -5,16 +5,7 @@ use std::sync::{Mutex, MutexGuard, PoisonError}; use promptforge_core_support::cancel::CancelHandle; use tokio::sync::Notify; -/// Why the current run's cancellation handle fired. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(super) enum CancelOrigin { - /// The operator explicitly cancelled the current turn. - Operator, - /// The supervisor retired an idle run for a new catalog generation. - Catalog, - /// The desktop host published a relaunched local Gateway generation. - Gateway, -} +pub(super) use super::supervisor::transition::CancelOrigin; /// State shared by input acceptance, the supervisor, and terminal events. pub(super) struct RunLifecycle { diff --git a/crates/workshop-server/src/session_agents/supervisor.rs b/crates/workshop-server/src/session_agents/supervisor.rs index e0d6c5b9..12967f79 100644 --- a/crates/workshop-server/src/session_agents/supervisor.rs +++ b/crates/workshop-server/src/session_agents/supervisor.rs @@ -16,10 +16,10 @@ use super::{ AgentSession, AgentSessions, CancelOrigin, SessionHost, SessionObserver, build_model_catalog, delta_stamp, ui_provider, }; - mod catalog; +#[cfg_attr(not(test), expect(dead_code, reason = "wiring lands separately"))] +pub(super) mod transition; use catalog::{wait_for_chat_catalog, wait_for_replacement_catalog}; - /// Spawns one session supervisor. Each run freezes one usable chat /// catalog; cancellation or a genuinely new usable generation relaunches /// over the retained event log. 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..d9b55d96 --- /dev/null +++ b/crates/workshop-server/src/session_agents/supervisor/transition.rs @@ -0,0 +1,424 @@ +//! 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, + pending_catalog_generation: Option, + 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, + pending_catalog_generation: None, + 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 { + match state.phase { + Phase::WaitingForCatalog => { + if disposition == CatalogDisposition::Unavailable { + return changed(state, SupervisorEffect::Wait(WaitFor::Catalog)); + } + state.catalog_generation = Some(generation); + relaunch(state) + } + Phase::Running => match disposition { + CatalogDisposition::Unavailable => changed( + state, + SupervisorEffect::Preserve(PreserveReason::CurrentRun), + ), + CatalogDisposition::Retained => { + if state + .catalog_generation + .is_some_and(|active| generation <= active) + { + return changed( + state, + SupervisorEffect::Preserve(PreserveReason::AlreadyHandled), + ); + } + state.catalog_generation = Some(generation); + changed( + state, + SupervisorEffect::Preserve(PreserveReason::CurrentRun), + ) + } + CatalogDisposition::Replacement => { + if newest_catalog(&state).is_some_and(|known| generation <= known) { + return changed( + state, + SupervisorEffect::Preserve(PreserveReason::AlreadyHandled), + ); + } + state.pending_catalog_generation = Some(generation); + 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 => { + if disposition != CatalogDisposition::Unavailable + && newest_catalog(&state).is_none_or(|known| generation > known) + { + state.pending_catalog_generation = Some(generation); + } + 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.pending_catalog_generation.is_some() { + 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 + .pending_catalog_generation + .take() + .or(state.catalog_generation) + else { + state.phase = Phase::WaitingForCatalog; + 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; + 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.pending_catalog_generation = None; + changed(state, SupervisorEffect::Close(reason)) +} + +fn newest_catalog(state: &SupervisorState) -> Option { + state + .pending_catalog_generation + .into_iter() + .chain(state.catalog_generation) + .max() +} + +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..77ae19b0 --- /dev/null +++ b/crates/workshop-server/src/session_agents/supervisor/transition/tests.rs @@ -0,0 +1,336 @@ +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 defers catalog retirement until settlement", + events: vec![ + catalog(1, CatalogDisposition::Retained), + SupervisorEvent::AcceptedInput(RUN_1), + catalog(2, CatalogDisposition::Replacement), + 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::Cancel(CancelOrigin::Catalog), + relaunch(RUN_2, 2, 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 interrupts accepted input immediately", + events: vec![ + catalog(1, CatalogDisposition::Retained), + SupervisorEvent::AcceptedInput(RUN_1), + SupervisorEvent::GatewayGeneration(8), + SupervisorEvent::TerminalSettlement(RUN_1), + completed(RUN_1, RunCompletion::Interrupted), + ], + effects: vec![ + relaunch(RUN_1, 1, 7), + SupervisorEffect::Preserve(PreserveReason::CurrentRun), + SupervisorEffect::Cancel(CancelOrigin::Gateway), + SupervisorEffect::Preserve(PreserveReason::CancellationPending), + relaunch(RUN_2, 1, 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/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index 45eb5e25..9c639daf 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -469,7 +469,7 @@ isProject: false - 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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 76005bd0..ca2f82ec 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -202,3 +202,6 @@ N73 | observation | Violates A117 @ crates/gateway/src/config_write.rs::Prepared 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 +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 From aadd9578113a8912d1a01cabf2972e6e9a00f4f6 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 17:51:09 -0700 Subject: [PATCH 79/86] Repair profile replacement architecture gate Make profile_replacement_policy_requires_restartable_rollback_and_fatal_shutdown inspect typed transaction structure after the profile switch extraction. Mask Rust non-code text, parse phase fields and bodies, and validate resource ownership, transition order, rollback reconstruction, fatal shutdown, and root delegation. - validate_profile_replacement_architecture composes focused checks against profile_switch.rs, lib.rs, and generation.rs instead of loose text-presence checks. - profile_replacement_architecture_rejects_adversarial_mutations proves the gate rejects ownership, publication, cancellation, shutdown, terminal, delegation, and reconstruction mutations. - No production file changes. This commit changes only architecture.rs. --- crates/gateway-stt/tests/it/architecture.rs | 735 +++++++++++++++++++- 1 file changed, 710 insertions(+), 25 deletions(-) diff --git a/crates/gateway-stt/tests/it/architecture.rs b/crates/gateway-stt/tests/it/architecture.rs index 4290dec5..7d5521d2 100644 --- a/crates/gateway-stt/tests/it/architecture.rs +++ b/crates/gateway-stt/tests/it/architecture.rs @@ -763,40 +763,725 @@ fn realtime_retirement_is_registry_owned_event_driven_and_keeps_state_pure() { } } -#[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 persistence = read(&crate_root("gateway").join("src/config_write.rs")); +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' '; + } + } +} - for policy in [ - "rollback: Option", - "restore_generation", - "impl Drop for SpeechReplacement", +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", + ], + ), ] { - assert!( - generation.contains(policy), - "speech replacement must retain {policy}" + 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(), ); } - for policy in [ - "PreparedPersistence::prepare", - "PersistenceCommitError::Determinate", - "PersistenceCommitError::Indeterminate", - "PROFILE_STAGE_TIMEOUT", - "state.shutdown.fire()", + + 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", ] { - assert!( - gateway.contains(policy), - "Gateway transaction policy must retain {policy}" + 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(), ); } - for policy in ["file.sync_all()", "std::fs::rename", "sync_parent"] { + 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!( - persistence.contains(policy), - "profile persistence must retain {policy}" + 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] From bd1f5be33e376ab2efbf794501d7dd5ad2c46d10 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 17:52:34 -0700 Subject: [PATCH 80/86] Classify every rotation crash checkpoint Keep fixed-size segment recovery within its aggregate disk budget by classifying each injected file-system boundary. Add commit_marker_written to FaultInjector, call checkpoint from sync_parent on all platforms, and preserve the observed commit decision after marker cleanup. Make the compaction, rotation, and sparse-cleanup fault loops prove exact checkpoint coverage. --- crates/gateway-logging/src/worker.rs | 79 +++++++++++++++++++++------- 1 file changed, 59 insertions(+), 20 deletions(-) diff --git a/crates/gateway-logging/src/worker.rs b/crates/gateway-logging/src/worker.rs index 98fe53ea..ad08fdd2 100644 --- a/crates/gateway-logging/src/worker.rs +++ b/crates/gateway-logging/src/worker.rs @@ -43,6 +43,8 @@ struct FaultInjector { simulated_crash: bool, #[cfg(test)] failed_operation: Option<&'static str>, + #[cfg(test)] + commit_marker_written: bool, } impl FaultInjector { @@ -75,6 +77,14 @@ impl FaultInjector { false } } + + #[cfg_attr(not(test), allow(clippy::unused_self))] + fn record_commit_marker(&mut self) { + #[cfg(test)] + { + self.commit_marker_written = true; + } + } } impl RotationLimits { @@ -246,19 +256,15 @@ fn remove_file_if_present(path: &Path) -> io::Result<()> { } } -#[expect( - clippy::unnecessary_wraps, - reason = "directory syncing is supported on Unix and intentionally a no-op elsewhere" -)] fn sync_parent(path: &Path, fault: &mut FaultInjector) -> io::Result<()> { + fault.checkpoint("sync parent directory")?; #[cfg(unix)] { - fault.checkpoint("sync parent directory")?; File::open(path.parent().unwrap_or_else(|| Path::new(".")))?.sync_all() } #[cfg(not(unix))] { - let _ = (path, fault); + let _ = path; Ok(()) } } @@ -582,6 +588,7 @@ fn rotate_files( 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 { @@ -1204,6 +1211,30 @@ mod tests { } } + 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"; @@ -1222,12 +1253,13 @@ mod tests { ); if result.is_ok() { completed = true; - assert!( - failures >= 6, - "the loop injected every staged replacement operation" + 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!( @@ -1258,6 +1290,7 @@ mod tests { #[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"); @@ -1283,9 +1316,9 @@ mod tests { ); if result.is_ok() { completed = true; - assert!( - failures >= 20, - "the loop injected every staged chain operation" + assert_eq!( + failures, fault.calls, + "the loop injected every rotation checkpoint independently" ); assert!( total_directory_file_bytes(&logs) <= disk_budget, @@ -1301,11 +1334,16 @@ mod tests { } 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}" ); - let committed = rotation_committed_path(¤t).exists(); + // 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() @@ -1341,6 +1379,10 @@ mod tests { 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" @@ -1376,15 +1418,12 @@ mod tests { let result = cleanup_rotation_with(¤t, &retained, &mut fault); if result.is_ok() { completed = true; - assert!( - failures >= 5, - "the loop injected every sparse cleanup operation" + assert_eq!( + failures, fault.calls, + "the loop injected every sparse cleanup checkpoint independently" ); } else { - assert!( - fault.failed_operation.is_some(), - "only an injected crash interrupts cleanup" - ); + assert_injected_checkpoint(&fault, fail_at, "cleanup"); assert!( total_directory_file_bytes(&logs) <= disk_budget, "interrupted cleanup never duplicates segment bytes" From 0b8b3a5dfa726c7e40478d198547e100a3a2d744 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 18:08:42 -0700 Subject: [PATCH 81/86] Route agent supervision through transitions Collect lifecycle, catalog, Gateway, and run-completion events before the pure transition table selects one effect. Execute cancellation, relaunch, history preservation, and close effects outside the reducer so one supervisor owns each run. - `EventCollector` subscribes before snapshot reads and prioritizes lifecycle events while collecting catalog, Gateway, and run results. `EffectExecutor` performs only reducer-selected effects. - `RunLifecycle` replaces cancellation-origin and accepted-turn branch state with run-scoped event publication. It retains only the current run identity and cancellation handle. - `SupervisorState` rejects stale catalog generations and waits when the catalog becomes unavailable. It defers catalog retirement through accepted-input settlement, lets Gateway replacement cancel immediately, and ignores duplicate run events for exactly-once effects. - `RunFactory` reuses the session `event_log` for each replacement run. `EffectExecutor` retains the latest usable catalog and Gateway snapshots so relaunch preserves history and current bindings. - `gateway_replacement_interrupts_a_catalog_wait_on_accepted_input`, `retained_catalog_generation_replays_on_the_replacement_gateway`, and `unavailable_catalog_waits_without_relaunching_stale_bindings` add asynchronous coverage for settlement override, retained replay, history reuse, and stale-binding exclusion. Design: new message-passing @ crates/workshop-server/src/session_agents/lifecycle.rs::RunLifecycle Design: removes oversized-unit @ crates/workshop-server/src/session_agents/supervisor.rs::spawn deps: AgentSessions,Arc,GatewayBinding,SessionHost,ToolCatalog,mpsc::UnboundedReceiver Design: new pure-function @ crates/workshop-server/src/session_agents/supervisor/catalog.rs::classify deps: Option<&[serde_json::Value]>,Option,u64 Design: new oversized-unit @ crates/workshop-server/src/session_agents/supervisor/effects.rs Design: new facade @ crates/workshop-server/src/session_agents/supervisor/effects.rs::EffectExecutor Design: new dispatch-on-tag @ crates/workshop-server/src/session_agents/supervisor/effects.rs::EffectExecutor::execute Design: new pure-function @ crates/workshop-server/src/session_agents/supervisor/effects.rs::failed_relaunch deps: RunId Design: new pure-function @ crates/workshop-server/src/session_agents/supervisor/effects.rs::binding_for_catalog deps: Option<&ChatCatalog>,RelaunchEffect Design: new pure-function @ crates/workshop-server/src/session_agents/supervisor/effects.rs::binding_for_gateway deps: &'a Arc,Option<&'a Arc>,RelaunchEffect Design: new oversized-unit @ crates/workshop-server/src/session_agents/supervisor/events.rs Design: new facade @ crates/workshop-server/src/session_agents/supervisor/events.rs::EventCollector Design: new clone-block @ crates/workshop-server/src/session_agents/supervisor/events.rs::EventCollector::next Design: extends oversized-unit @ crates/workshop-server/src/session_agents/supervisor/transition.rs Design: new oversized-unit @ crates/workshop-server/tests/it/agents.rs::gateway_replacement_interrupts_a_catalog_wait_on_accepted_input Design: new oversized-unit @ crates/workshop-server/tests/it/agents.rs::retained_catalog_generation_replays_on_the_replacement_gateway Design: new clone-block @ crates/workshop-server/tests/it/agents.rs::retained_catalog_generation_replays_on_the_replacement_gateway Design: new oversized-unit @ crates/workshop-server/tests/it/agents.rs::unavailable_catalog_waits_without_relaunching_stale_bindings Design: new clone-block @ crates/workshop-server/tests/it/agents.rs::unavailable_catalog_waits_without_relaunching_stale_bindings Violates: A99 - SupervisorEffect descendant cancellation propagation and sibling isolation are not determinable from diff Plan: vibe/2026-09-07-1-promptforge-debt.md --- crates/workshop-server/module-ceilings.toml | 21 +- crates/workshop-server/src/session_agents.rs | 75 ++-- .../src/session_agents/lifecycle.rs | 101 +++-- .../src/session_agents/supervisor.rs | 191 ++------- .../src/session_agents/supervisor/catalog.rs | 85 ++-- .../src/session_agents/supervisor/effects.rs | 286 ++++++++++++++ .../src/session_agents/supervisor/events.rs | 137 +++++++ .../session_agents/supervisor/transition.rs | 91 ++--- .../supervisor/transition/tests.rs | 16 +- crates/workshop-server/tests/it/agents.rs | 371 +++++++++++++++++- vibe/2026-09-07-1-promptforge-debt.md | 2 +- vibe/archdoc-next.md | 4 +- 12 files changed, 1005 insertions(+), 375 deletions(-) create mode 100644 crates/workshop-server/src/session_agents/supervisor/effects.rs create mode 100644 crates/workshop-server/src/session_agents/supervisor/events.rs diff --git a/crates/workshop-server/module-ceilings.toml b/crates/workshop-server/module-ceilings.toml index 0f2d9ffc..5258968d 100644 --- a/crates/workshop-server/module-ceilings.toml +++ b/crates/workshop-server/module-ceilings.toml @@ -200,16 +200,19 @@ # Thinking LED, which only on_assistant_reply's idle otherwise clears) # and its pinning test. "session_agents.rs" = 1028 -# Cancellation provenance moved into the pure supervisor transition model; -# this module retains the shared handle and accepted-turn notification. +# 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 -# New module: one agent session's run lifecycle across turn cancellation, -# delayed catalog readiness, and usable chat-catalog replacement. -"session_agents/supervisor.rs" = 170 -# Catalog-generation waits split from agent run orchestration. -"session_agents/supervisor/catalog.rs" = 53 -# New pure supervisor event reducer. The async loop remains unchanged until -# its separate wiring step. +# 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 diff --git a/crates/workshop-server/src/session_agents.rs b/crates/workshop-server/src/session_agents.rs index abfef8e2..359f25ff 100644 --- a/crates/workshop-server/src/session_agents.rs +++ b/crates/workshop-server/src/session_agents.rs @@ -34,7 +34,7 @@ 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_core_support::cancel::CancelHandle; @@ -44,7 +44,7 @@ use promptforge_core_support::observe::{Observation, Observer}; use promptforge_model_client::client::GatewayClient as ModelClient; use promptforge_model_client::client::StreamDelta; use promptforge_model_client::model::{ModelCatalog, ModelDescriptor, ModelId, ThinkingMode}; -use tokio::sync::{Notify, broadcast}; +use tokio::sync::{broadcast, mpsc}; use crate::backoff::ReconnectBackoff; use crate::catalog::{CatalogBus, is_chat_capable}; @@ -56,7 +56,8 @@ use crate::protocol::{Activity, AgentDeltaKind, InputFrame, InputResponse}; use crate::push::Push; use crate::workspace::Workspace; -use self::lifecycle::{CancelOrigin, RunLifecycle}; +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 @@ -231,7 +232,8 @@ impl AgentSessions { WorkshopObserver::new(Some(&log_path)) .map_err(|source| LaunchRefusal::SessionState { source })?, ); - let lifecycle = Arc::new(RunLifecycle::new()); + 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); @@ -247,8 +249,6 @@ impl AgentSessions { input_frames, deltas, errors, - closing: AtomicBool::new(false), - closed: Notify::new(), }); self.lock().insert(id, Arc::clone(&session)); supervisor::spawn( @@ -256,6 +256,7 @@ impl AgentSessions { self.clone(), self.inner.host.clone(), self.inner.gateway.clone(), + events, ); Ok(session) } @@ -369,11 +370,6 @@ pub(crate) struct AgentSession { /// ended in error. Ephemeral like the deltas - errors never enter /// the event log. errors: broadcast::Sender, - /// Set by [`close`](Self::close): the supervisor ends instead of - /// relaunching. - closing: AtomicBool, - /// Wakes a supervisor that is waiting for its first usable catalog. - closed: Notify, } impl fmt::Debug for AgentSession { @@ -397,16 +393,14 @@ impl AgentSession { self.errors.subscribe() } - /// Durably accepts one input and resumes its wait while excluding a - /// catalog cancellation from the observation-to-completion boundary. + /// 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 mut state = self.lifecycle.lock(); - let previously_accepted = state.accepted_turn; - state.accepted_turn = true; + let accepted_run = self.lifecycle.accept_input(); let result = deliver_input_response_before_completion( self.log.as_ref(), &self.waits, @@ -415,8 +409,8 @@ impl AgentSession { response, after_acceptance, ); - if result.is_err() { - state.accepted_turn = previously_accepted; + if let (Err(_), Some(run)) = (&result, accepted_run) { + self.lifecycle.settle_turn(run); } result } @@ -426,46 +420,28 @@ impl AgentSession { /// frame), and the supervisor relaunches the program over the /// retained event log with a fresh handle. pub(crate) fn cancel_turn(&self) { - self.lifecycle.cancel(CancelOrigin::Operator); + 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.closed.notify_waiters(); + self.lifecycle.close(); } /// Installs and retains the next run's fresh cancel handle. - fn arm_cancel(&self) -> CancelHandle { - let fresh = self.lifecycle.arm(); - // 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 - } - - /// Requests catalog retirement, deferring while accepted input is active. - fn cancel_for_catalog(&self) -> bool { - self.lifecycle.cancel_for_catalog() - } - - /// Retires the current run immediately after a Gateway replacement. - fn cancel_for_gateway(&self) { - self.lifecycle.cancel(CancelOrigin::Gateway); + fn arm_cancel(&self, run: RunId) -> CancelHandle { + self.lifecycle.arm(run) } - /// Waits until the accepted turn reaches a terminal event. - async fn wait_until_turn_settled(&self) { - self.lifecycle.wait_until_settled().await; + /// Cancels the run selected by a reducer effect. + fn cancel_current_run(&self) { + self.lifecycle.cancel_current(); } - /// Returns why the current run was cancelled. - fn cancel_origin(&self) -> Option { - self.lifecycle.origin() + /// Clears the lifecycle identity after a run ends. + fn finish_run(&self, run: RunId) { + self.lifecycle.finish(run); } } @@ -498,7 +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_turn(); + 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 @@ -534,7 +510,7 @@ impl Observer for SessionObserver { model, metrics, ); - self.lifecycle.settle_turn(); + self.lifecycle.settle_current_turn(); self.rounds.fetch_add(1, Ordering::SeqCst); self.backoff.record_useful_work(); self.push.push_idle(); @@ -1006,13 +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()), + 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 index 988f4b16..7d72882c 100644 --- a/crates/workshop-server/src/session_agents/lifecycle.rs +++ b/crates/workshop-server/src/session_agents/lifecycle.rs @@ -1,95 +1,94 @@ -//! Cancellation provenance and accepted-turn settlement. +//! Supervisor event publication and current-run cancellation. use std::sync::{Mutex, MutexGuard, PoisonError}; use promptforge_core_support::cancel::CancelHandle; -use tokio::sync::Notify; +use tokio::sync::mpsc; -pub(super) use super::supervisor::transition::CancelOrigin; +use super::supervisor::transition::{RunId, SupervisorEvent}; -/// State shared by input acceptance, the supervisor, and terminal events. +/// Synchronous producers for one supervisor's typed event stream. pub(super) struct RunLifecycle { state: Mutex, - settled: Notify, + events: mpsc::UnboundedSender, } -/// The current run's cancellation and accepted-turn state. -pub(super) struct RunState { +/// The current run identity and cancellation handle. +struct RunState { cancel: CancelHandle, - origin: Option, - pub(super) accepted_turn: bool, + run: Option, } impl RunLifecycle { - /// Creates the lifecycle before the first run is armed. - pub(super) fn new() -> Self { + /// 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(), - origin: None, - accepted_turn: false, + run: None, }), - settled: Notify::new(), + events, } } /// Locks lifecycle state, recovering from a panicking peer. - pub(super) fn lock(&self) -> MutexGuard<'_, RunState> { + fn lock(&self) -> MutexGuard<'_, RunState> { self.state.lock().unwrap_or_else(PoisonError::into_inner) } - /// Arms a fresh run and clears the prior stop provenance. - pub(super) fn arm(&self) -> CancelHandle { + /// 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.origin = None; - state.accepted_turn = false; + state.run = Some(run); fresh } - /// Cancels immediately for an operator request. - pub(super) fn cancel(&self, origin: CancelOrigin) { - let mut state = self.lock(); - state.origin = Some(origin); - state.accepted_turn = false; - state.cancel.cancel(); - self.settled.notify_waiters(); + /// Publishes an operator cancellation for reducer ownership. + pub(super) fn operator_cancel(&self) { + self.send(SupervisorEvent::OperatorCancellation); } - /// Cancels for catalog replacement only when no accepted turn is active. - pub(super) fn cancel_for_catalog(&self) -> bool { - let mut state = self.lock(); - if state.accepted_turn { - return false; + /// 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); } - state.origin = Some(CancelOrigin::Catalog); - state.cancel.cancel(); - true } - /// Records that the accepted turn reached a durable terminal event. - pub(super) fn settle_turn(&self) { + /// 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.accepted_turn { - state.accepted_turn = false; - self.settled.notify_waiters(); + if state.run == Some(run) { + state.run = None; } } - /// Waits cancellation-safely until no accepted turn remains. - pub(super) async fn wait_until_settled(&self) { - loop { - let notified = self.settled.notified(); - if !self.lock().accepted_turn { - return; - } - notified.await; - } + /// Publishes session close for reducer ownership. + pub(super) fn close(&self) { + self.send(SupervisorEvent::Close); } - /// Returns the current run's cancellation provenance. - pub(super) fn origin(&self) -> Option { - self.lock().origin + /// 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/supervisor.rs b/crates/workshop-server/src/session_agents/supervisor.rs index 12967f79..eb187a74 100644 --- a/crates/workshop-server/src/session_agents/supervisor.rs +++ b/crates/workshop-server/src/session_agents/supervisor.rs @@ -1,25 +1,22 @@ //! Agent-run supervision across cancellation and catalog generations. use std::sync::Arc; -use std::sync::atomic::Ordering; -use promptforge_agent::{AgentConfig, AgentError, AgentLimits, run_agent_with_client}; -use promptforge_core_support::observe::Observer; -use promptforge_store::StoreRef; use promptforge_tools::{Tool, ToolCatalog}; +use tokio::sync::mpsc; use crate::gateway_binding::GatewayBinding; use crate::input::UserInputTool; -use crate::protocol::Activity; -use super::{ - AgentSession, AgentSessions, CancelOrigin, SessionHost, SessionObserver, build_model_catalog, - delta_stamp, ui_provider, -}; +use super::{AgentSession, AgentSessions, SessionHost}; mod catalog; -#[cfg_attr(not(test), expect(dead_code, reason = "wiring lands separately"))] +mod effects; +mod events; pub(super) mod transition; -use catalog::{wait_for_chat_catalog, wait_for_replacement_catalog}; +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. @@ -28,6 +25,7 @@ pub(super) fn spawn( registry: AgentSessions, host: SessionHost, gateway: GatewayBinding, + lifecycle: mpsc::UnboundedReceiver, ) { tokio::spawn(async move { let tool: Arc = Arc::new(UserInputTool::new( @@ -42,159 +40,32 @@ pub(super) fn spawn( return; } }; - let store = StoreRef::memory(); - let observer = observer(&session, &host); - let on_delta = delta_stamp(&session, &host.push); - let ui = ui_provider(&host.menu, &host.workspace); - let mut catalog_generation = host.catalog.subscribe_chat_generation(); - let mut gateway_generation = gateway.subscribe(); + 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 Some(chat_catalog) = - wait_for_chat_catalog(&session, &host.catalog, &mut catalog_generation).await - else { - break; - }; - let active_generation = chat_catalog.generation; - let active_models = chat_catalog.models; - let models = build_model_catalog(Some(active_models.clone())); - let gateway_snapshot = gateway.snapshot(); - let active_gateway_generation = gateway_snapshot.generation(); - let Some(client) = gateway_snapshot.model_client() else { - let message = "the replacement Gateway credentials cannot make a model client"; - let _ = session.errors.send(message.to_owned()); - host.push - .push_failure("Agent failed", message, Activity::General); - break; - }; - let run_cancel = session.arm_cancel(); - let config = AgentConfig { - name: session.agent.clone(), - execution: session.id.clone(), - observer: Arc::clone(&observer), - cancel: run_cancel.clone(), - event_log: Some(Arc::clone(&session.log) as _), - on_delta: Some(Arc::clone(&on_delta)), - ui: Some(Arc::clone(&ui)), - limits: AgentLimits::default(), - }; - let run = run_agent_with_client( - &session.source, - &tools, - &models, - &store, - config, - Some(client.clone()), - ); - tokio::pin!(run); - let result = tokio::select! { - result = &mut run => result, - replacement = wait_for_replacement_catalog( - &host.catalog, - &mut catalog_generation, - active_generation, - &active_models, - ) => { - if replacement.is_none() { - run.await - } else { - loop { - if session.cancel_for_catalog() { - break run.await; - } - tokio::select! { - result = &mut run => break result, - () = session.wait_until_turn_settled() => {} - } - } - } - } - replaced = wait_for_gateway_replacement( - &mut gateway_generation, - active_gateway_generation, - ) => { - if replaced { - session.cancel_for_gateway(); - } - run.await - } + let collected = match pending_event.take() { + Some(event) => CollectedEvent::Supervisor(event), + None => executor.next_event(&mut collector).await, }; - if run_finished(result, &session, &host) { - break; + 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); }); } - -/// Reports one run ending and answers whether the supervisor is finished. -fn run_finished( - result: Result<(), AgentError>, - session: &AgentSession, - host: &SessionHost, -) -> bool { - match (result, session.cancel_origin()) { - (Err(AgentError::Interrupted), _) if !session.closing.load(Ordering::SeqCst) => { - report_cancel_origin(session); - false - } - (Err(AgentError::Interrupted) | Ok(()), _) => true, - (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); - true - } - } -} - -/// Builds the observer shared by every generation of one session. -fn observer(session: &AgentSession, host: &SessionHost) -> 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), - }) -} - -/// Records catalog retirement separately from explicit operator cancellation. -fn report_cancel_origin(session: &AgentSession) { - match session.cancel_origin() { - Some(CancelOrigin::Operator) => {} - Some(CancelOrigin::Catalog) => tracing::debug!( - session = %session.id, - "agent run retired for a new catalog generation" - ), - Some(CancelOrigin::Gateway) => tracing::debug!( - session = %session.id, - "agent run retired for a new gateway generation" - ), - None => tracing::debug!( - session = %session.id, - "agent run interrupted without a supervisor cancellation origin" - ), - } -} - -/// Waits until the host publishes a different Gateway generation. -async fn wait_for_gateway_replacement( - generation: &mut tokio::sync::watch::Receiver, - active: u64, -) -> bool { - loop { - if *generation.borrow_and_update() != active { - return true; - } - if generation.changed().await.is_err() { - return false; - } - } -} diff --git a/crates/workshop-server/src/session_agents/supervisor/catalog.rs b/crates/workshop-server/src/session_agents/supervisor/catalog.rs index a6bde3dd..0c5c1ba9 100644 --- a/crates/workshop-server/src/session_agents/supervisor/catalog.rs +++ b/crates/workshop-server/src/session_agents/supervisor/catalog.rs @@ -1,53 +1,56 @@ -//! Catalog-generation waits for one agent supervisor. - -use std::sync::atomic::Ordering; +//! Typed catalog-event collection for one agent supervisor. use crate::catalog::{CatalogBus, ChatCatalog}; -use super::super::AgentSession; +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, +} -/// Waits for the first non-empty chat catalog or session close. -pub(super) async fn wait_for_chat_catalog( - session: &AgentSession, +/// Collects the receiver's current catalog generation without waiting. +pub(super) fn current_catalog_event( catalog: &CatalogBus, generation: &mut tokio::sync::watch::Receiver, -) -> Option { - loop { - let closed = session.closed.notified(); - tokio::pin!(closed); - if session.closing.load(Ordering::SeqCst) { - return None; - } - if let Some(chat) = catalog.latest_chat() { - return Some(chat); - } - tokio::select! { - () = &mut closed => {} - changed = generation.changed() => { - if changed.is_err() { - return None; - } - } - } - } +) -> CatalogEvent { + let observed = *generation.borrow_and_update(); + classify(catalog.latest_chat(), observed, None) } -/// Waits for a usable generation with bindings different from this run. -pub(super) async fn wait_for_replacement_catalog( +/// Waits for and classifies the next catalog generation. +pub(super) async fn next_catalog_event( catalog: &CatalogBus, generation: &mut tokio::sync::watch::Receiver, - active_generation: u64, - active_models: &[serde_json::Value], -) -> Option { - loop { - if generation.changed().await.is_err() { - return None; - } - if let Some(chat) = catalog.latest_chat() - && chat.generation != active_generation - && chat.models != active_models - { - return Some(chat); - } + 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 index d9b55d96..02697812 100644 --- a/crates/workshop-server/src/session_agents/supervisor/transition.rs +++ b/crates/workshop-server/src/session_agents/supervisor/transition.rs @@ -150,7 +150,8 @@ pub(in crate::session_agents) struct SupervisorState { active_run: Option, next_run: u64, catalog_generation: Option, - pending_catalog_generation: Option, + observed_catalog_generation: Option, + catalog_retirement_pending: bool, gateway_generation: u64, accepted_run: Option, } @@ -163,7 +164,8 @@ impl SupervisorState { active_run: None, next_run: 1, catalog_generation: None, - pending_catalog_generation: None, + observed_catalog_generation: None, + catalog_retirement_pending: false, gateway_generation, accepted_run: None, } @@ -204,43 +206,38 @@ fn catalog_changed( 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)); } - state.catalog_generation = Some(generation); relaunch(state) } Phase::Running => match disposition { - CatalogDisposition::Unavailable => changed( - state, - SupervisorEffect::Preserve(PreserveReason::CurrentRun), - ), - CatalogDisposition::Retained => { - if state - .catalog_generation - .is_some_and(|active| generation <= active) - { - return changed( - state, - SupervisorEffect::Preserve(PreserveReason::AlreadyHandled), - ); - } - state.catalog_generation = Some(generation); - changed( - state, - SupervisorEffect::Preserve(PreserveReason::CurrentRun), - ) + 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 => { - if newest_catalog(&state).is_some_and(|known| generation <= known) { - return changed( - state, - SupervisorEffect::Preserve(PreserveReason::AlreadyHandled), - ); - } - state.pending_catalog_generation = Some(generation); + state.catalog_retirement_pending = true; if state.accepted_run == state.active_run { changed(state, SupervisorEffect::Wait(WaitFor::TerminalSettlement)) } else { @@ -249,17 +246,10 @@ fn catalog_changed( } } }, - Phase::Cancelling => { - if disposition != CatalogDisposition::Unavailable - && newest_catalog(&state).is_none_or(|known| generation > known) - { - state.pending_catalog_generation = Some(generation); - } - changed( - state, - SupervisorEffect::Preserve(PreserveReason::CancellationPending), - ) - } + Phase::Cancelling => changed( + state, + SupervisorEffect::Preserve(PreserveReason::CancellationPending), + ), Phase::Closed => changed(state, SupervisorEffect::Preserve(PreserveReason::Closed)), } } @@ -345,7 +335,7 @@ fn turn_settled(mut state: SupervisorState, run: RunId) -> SupervisorTransition ); } state.accepted_run = None; - if state.pending_catalog_generation.is_some() { + if state.catalog_retirement_pending { state.phase = Phase::Cancelling; changed(state, SupervisorEffect::Cancel(CancelOrigin::Catalog)) } else { @@ -377,12 +367,9 @@ fn run_completed( } fn relaunch(mut state: SupervisorState) -> SupervisorTransition { - let Some(catalog_generation) = state - .pending_catalog_generation - .take() - .or(state.catalog_generation) - else { + 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); @@ -391,6 +378,7 @@ fn relaunch(mut state: SupervisorState) -> SupervisorTransition { state.active_run = Some(run); state.accepted_run = None; state.phase = Phase::Running; + state.catalog_retirement_pending = false; let effect = RelaunchEffect { run, catalog_generation, @@ -404,18 +392,11 @@ fn close(mut state: SupervisorState, reason: CloseReason) -> SupervisorTransitio state.phase = Phase::Closed; state.active_run = None; state.accepted_run = None; - state.pending_catalog_generation = None; + state.catalog_generation = None; + state.catalog_retirement_pending = false; changed(state, SupervisorEffect::Close(reason)) } -fn newest_catalog(state: &SupervisorState) -> Option { - state - .pending_catalog_generation - .into_iter() - .chain(state.catalog_generation) - .max() -} - fn changed(state: SupervisorState, effect: SupervisorEffect) -> SupervisorTransition { SupervisorTransition { state, effect } } diff --git a/crates/workshop-server/src/session_agents/supervisor/transition/tests.rs b/crates/workshop-server/src/session_agents/supervisor/transition/tests.rs index 77ae19b0..f31f3b79 100644 --- a/crates/workshop-server/src/session_agents/supervisor/transition/tests.rs +++ b/crates/workshop-server/src/session_agents/supervisor/transition/tests.rs @@ -85,20 +85,24 @@ fn transition_table_covers_wait_cancel_and_relaunch_effects() { phase: Phase::Running, }, Scenario { - name: "accepted input defers catalog retirement until settlement", + 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), - relaunch(RUN_2, 2, 7), + SupervisorEffect::Wait(WaitFor::Catalog), + relaunch(RUN_2, 4, 7), ], phase: Phase::Running, }, @@ -125,10 +129,12 @@ fn transition_table_covers_preservation_and_immediate_retirement() { phase: Phase::Running, }, Scenario { - name: "gateway replacement interrupts accepted input immediately", + 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), @@ -136,9 +142,11 @@ fn transition_table_covers_preservation_and_immediate_retirement() { 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, 1, 8), + relaunch(RUN_2, 3, 8), ], phase: Phase::Running, }, diff --git a/crates/workshop-server/tests/it/agents.rs b/crates/workshop-server/tests/it/agents.rs index 410422a7..4bfb7250 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,24 @@ 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"); + gateway_updater(state) + .replace_sidecar(&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 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 +208,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 +494,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/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index 9c639daf..b7d7b2cd 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -479,7 +479,7 @@ isProject: false - 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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index ca2f82ec..88acffaa 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -202,6 +202,6 @@ N73 | observation | Violates A117 @ crates/gateway/src/config_write.rs::Prepared 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 +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 +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 From 8d7963b70a22227457baa2205bb64113b483688a Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 18:38:38 -0700 Subject: [PATCH 82/86] Add validated sidecar connection capability Add a construction-controlled sidecar capability that requires one stable operating-system process boot to bracket health and bearer checks on one socket. Reject mixed process or listener identities and keep bearer and untrusted metadata out of diagnostics. - `ValidatedConnection` keeps construction state private and exposes `validate`, `port`, `pid`, `epoch`, `version`, `started_at`, `same_boot`, and the document-hidden `api_key` accessor needed for a consumer snapshot. `ProcessIdentity` records the executable and platform start marker before and after the network proof. - `probe_connection` carries `/health` and `/v1/models` over one framed HTTP connection. It retries only before health succeeds and rejects socket loss before bearer acceptance. - `ConnectionFile` and `ValidatedConnection` redact bearer, version, and start metadata from debug output. `StaleReason` and `request_shutdown` report fixed classifications or numeric status without reflecting untrusted response text. - `compile_fail`, `connection_proof_cannot_mix_health_and_bearer_across_endpoints`, `a_process_boot_change_during_the_network_proof_is_rejected`, and `forged_file_identity_cannot_alias_a_reused_process` cover private construction and process or listener replacement races. - `Resolution::Attach` still returns `ConnectionFile`; Workshop publication remains deferred to capability-based updater wiring. Design: new encapsulated-invariant @ crates/shared-sidecar/src/validated.rs::ValidatedConnection boundary: pub Design: new facade @ crates/shared-sidecar/src/validated.rs::ValidatedConnection boundary: pub Design: new surface-growth @ crates/shared-sidecar/src/validated.rs::ValidatedConnection boundary: pub Design: new surface-growth @ crates/shared-sidecar/src/stale.rs::StaleReason boundary: pub Design: new shared-parameter-cluster @ crates/shared-sidecar/src/health.rs::probe_connection deps: &str,&str,&str,Duration Design: new shared-parameter-cluster @ crates/shared-sidecar/src/health.rs::probe_connection_once deps: &str,&str,&str Design: new flag-parameter @ crates/shared-sidecar/src/health.rs::write_request deps: &mut TcpStream,&str,&str,Option<&str>,bool Design: new pure-function @ crates/shared-sidecar/src/health.rs::response_content_length deps: &str Design: new pure-function @ crates/shared-sidecar/src/health.rs::response_status deps: &str Design: new pure-function @ crates/shared-sidecar/src/validated.rs::image_name_matches deps: &Path,&str Design: new pure-function @ crates/shared-sidecar/src/validated.rs::image_file_name_matches deps: &OsStr,&str Design: new oversized-unit @ crates/shared-sidecar/src/validated.rs Design: new clone-block @ crates/shared-sidecar/src/validated.rs::fixture_gateway Design: new clone-block @ crates/shared-sidecar/src/stale.rs::fixture_gateway Design: new clone-block @ crates/shared-sidecar/src/lock.rs::fixture_gateway Design: new clone-block @ crates/shared-sidecar/src/stale.rs::a_transiently_silent_health_endpoint_is_not_stale Deferred: Workshop Gateway publication remains deferred to capability-based updater wiring Plan: vibe/2026-09-07-1-promptforge-debt.md --- crates/shared-sidecar/README.md | 5 +- crates/shared-sidecar/src/file.rs | 53 ++- crates/shared-sidecar/src/health.rs | 278 ++++++++++++- crates/shared-sidecar/src/lib.rs | 11 +- crates/shared-sidecar/src/lock.rs | 15 +- crates/shared-sidecar/src/shutdown.rs | 25 +- crates/shared-sidecar/src/stale.rs | 142 +++---- crates/shared-sidecar/src/sys.rs | 48 ++- crates/shared-sidecar/src/sys/linux.rs | 28 +- crates/shared-sidecar/src/sys/macos.rs | 78 +++- crates/shared-sidecar/src/sys/windows.rs | 49 ++- crates/shared-sidecar/src/validated.rs | 492 +++++++++++++++++++++++ vibe/2026-09-07-1-promptforge-debt.md | 2 +- vibe/archdoc-next.md | 8 + 14 files changed, 1075 insertions(+), 159 deletions(-) create mode 100644 crates/shared-sidecar/src/validated.rs 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 7bcf278a..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}; @@ -51,3 +53,4 @@ pub use crate::shutdown::{ShutdownError, request_shutdown}; #[doc(hidden)] pub use crate::stale::resolve_for_test; 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 131097fa..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,9 +90,9 @@ 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)) } @@ -133,30 +123,7 @@ pub(crate) fn is_running_named(run_dir: &Path, image_name: &str) -> bool { /// 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. @@ -172,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::*; @@ -248,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 @@ -328,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"); 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..55611526 --- /dev/null +++ b/crates/shared-sidecar/src/validated.rs @@ -0,0 +1,492 @@ +//! 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) + } + + 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/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index b7d7b2cd..a55bc547 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -490,7 +490,7 @@ isProject: false - 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 +### 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 88acffaa..1932d6de 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -205,3 +205,11 @@ N76 | observation | Violates A96 @ crates/workshop-server/ui/src/services/realti 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 From c335bd0dd12e8b43c361881b7a1a9242cf8abec2 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 19:20:10 -0700 Subject: [PATCH 83/86] Require capability-based Gateway publication Require every production sidecar replacement to present a validated process capability. Publish the endpoint, bearer, clients, generation, and local identity as one immutable snapshot so readers cannot observe mixed state. Keep configured LAN gateways outside local process ownership and redact credentials from diagnostic output. Preserve test-only construction behind a feature gate. - `replace_sidecar` now accepts only `ValidatedConnection`; raw `ConnectionFile` publication and `GatewayError::InvalidSidecar` leave the production API. - `GatewayBinding` builds all `GatewaySnapshot` fields before one `ArcSwap` store and then notifies consumers. The snapshot retains the validated boot beside its normalized URL, bearer, HTTP client, model client, and generation. - `synchronized_reads_never_observe_a_torn_replacement_snapshot` samples 256 synchronized publications and accepts only complete old or new generations. - `supervision_lives_past_sixty_seconds_then_propagates_a_configured_key_edit_atomically` validates a real named child, republishes a changed key on the same port through the production updater, and proves the next proxied request succeeds. - `from_config` and fixture replacement publish no local identity, so explicit LAN targets remain outside sidecar supervision. `GatewaySnapshot` debug output emits `` for `api_key`. - `GatewaySlot` remains a second identity owner for quit handling; removal waits for authoritative-snapshot shutdown wiring. Design: extends facade @ crates/workshop-server/src/gateway_binding.rs::GatewayBinding Design: extends parameter-object @ crates/workshop-server/src/gateway_binding.rs::GatewayBinding Design: extends shared-mutable-state @ crates/workshop-server/src/gateway_binding.rs::GatewayBinding Design: extends surface-growth @ crates/workshop-server/src/gateway_binding.rs::GatewayUpdater boundary: pub instead-of: hidden-dependency: raw connection files cannot cross publication Design: removes surface-growth @ crates/workshop-server/src/gateway.rs::GatewayError::InvalidSidecar boundary: pub Design: replaces facade @ crates/workshop-server/src/fixtures.rs was: crates/workshop-server/src/lib.rs::fixtures Design: replaces surface-growth @ crates/workshop-server/src/fixtures.rs::gateway_updater boundary: pub was: crates/workshop-server/src/lib.rs::fixtures::gateway_updater Design: new surface-growth @ crates/workshop-server/src/fixtures.rs::replace_gateway deps: &crate::GatewayUpdater,&str,&str boundary: pub Design: new shim @ crates/workshop-server/src/fixtures.rs::replace_gateway deps: &crate::GatewayUpdater,&str,&str boundary: pub Design: new parameter-object @ crates/workshop-server/src/resolve.rs::ResolvedGateway Design: new shared-parameter-cluster @ crates/workshop-server/src/gateway_binding.rs::GatewayBinding::new_with_identity Design: new shared-parameter-cluster @ crates/workshop-server/src/gateway_binding.rs::GatewayBinding::replace_with_identity Design: new shared-parameter-cluster @ crates/workshop-server/src/gateway_binding.rs::build_snapshot deps: &str,&str,Option,u64 Design: new oversized-unit @ crates/workshop-server/src/gateway_binding/tests.rs Design: new oversized-unit @ crates/workshop-server/src/gateway_binding/tests/atomic.rs Design: new oversized-unit @ crates/workshop-server/src/test_gateway.rs Design: new oversized-unit @ crates/workshop/src/gateway.rs::tests::supervision_lives_past_sixty_seconds_then_propagates_a_configured_key_edit_atomically Design: new clone-block @ crates/workshop-server/src/test_gateway.rs::ValidatedGateway::spawn Design: new clone-block @ crates/workshop/src/gateway.rs::tests::NamedGateway::spawn Deferred: remove GatewaySlot and route quit through the current validated snapshot Plan: vibe/2026-09-07-1-promptforge-debt.md --- crates/shared-sidecar/src/validated.rs | 6 +- crates/workshop-server/README.md | 2 + crates/workshop-server/module-ceilings.toml | 16 +- crates/workshop-server/src/app.rs | 8 +- crates/workshop-server/src/fixtures.rs | 59 +++++ crates/workshop-server/src/gateway.rs | 8 - crates/workshop-server/src/gateway_binding.rs | 129 ++++++----- .../src/gateway_binding/tests.rs | 94 ++++++++ .../src/gateway_binding/tests/atomic.rs | 80 +++++++ crates/workshop-server/src/lib.rs | 47 +--- crates/workshop-server/src/resolve.rs | 82 +++++-- crates/workshop-server/src/test_gateway.rs | 163 +++++++++++++ crates/workshop-server/tests/common/mod.rs | 19 +- crates/workshop-server/tests/it/agents.rs | 20 +- crates/workshop-server/tests/it/chat_gate.rs | 2 +- .../tests/it/chat_gate/recovery.rs | 18 +- crates/workshop/Cargo.toml | 1 + crates/workshop/src/gateway.rs | 218 ++++++++++++++++-- vibe/2026-09-07-1-promptforge-debt.md | 2 +- vibe/archdoc-next.md | 2 + 20 files changed, 766 insertions(+), 210 deletions(-) create mode 100644 crates/workshop-server/src/fixtures.rs create mode 100644 crates/workshop-server/src/gateway_binding/tests.rs create mode 100644 crates/workshop-server/src/gateway_binding/tests/atomic.rs create mode 100644 crates/workshop-server/src/test_gateway.rs diff --git a/crates/shared-sidecar/src/validated.rs b/crates/shared-sidecar/src/validated.rs index 55611526..ab1691a3 100644 --- a/crates/shared-sidecar/src/validated.rs +++ b/crates/shared-sidecar/src/validated.rs @@ -47,8 +47,8 @@ const LIVENESS_BUDGET: Duration = Duration::from_secs(2); /// }; /// ``` /// -/// Even with the public test-fixture feature enabled, external code cannot -/// choose the process image used to mint a production capability: +/// No test-fixture feature exposes another production-capability +/// constructor: /// /// ```compile_fail /// use shared_sidecar::{ConnectionFile, ValidatedConnection}; @@ -61,7 +61,7 @@ const LIVENESS_BUDGET: Duration = Duration::from_secs(2); /// version: "test".into(), /// started_at: "2026-09-07T00:00:00Z".into(), /// }; -/// let _ = ValidatedConnection::validate_for_test(raw, "my-test-binary"); +/// let _ = ValidatedConnection::validate_for_test(raw); /// ``` /// /// The crate-private named validator is equally unavailable: diff --git a/crates/workshop-server/README.md b/crates/workshop-server/README.md index 051f185b..0296f0fd 100644 --- a/crates/workshop-server/README.md +++ b/crates/workshop-server/README.md @@ -62,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. diff --git a/crates/workshop-server/module-ceilings.toml b/crates/workshop-server/module-ceilings.toml index 5258968d..bd75ef50 100644 --- a/crates/workshop-server/module-ceilings.toml +++ b/crates/workshop-server/module-ceilings.toml @@ -60,6 +60,9 @@ # `not_granted`) and its case in the seam test. Shrank in the chat-relay # excision: `BadRequest` and `StreamUnsupported` left with their tests. "error.rs" = 494 +# Integration-test exports and feature-gated setup live outside the crate +# root so fixture growth cannot regrow public composition. +"fixtures.rs" = 57 # Grew by the config-panel proxy seam: the `base_url` accessor, # `ForwardedResponse`, and the `forward` relay the /gateway/api route # calls - one more request shape on the same gateway HTTP client. Shrank @@ -70,6 +73,11 @@ # 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" = 95 +# Deterministically synchronized publisher and reader coverage for complete +# immutable Gateway generations. +"gateway_binding/tests/atomic.rs" = 80 # Split from gateway.rs: fixed-target authenticated WebSocket connections # for the Realtime relay path. "gateway/socket.rs" = 94 @@ -148,8 +156,9 @@ # 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 # Asset route handlers and their tests are separate responsibilities. The # split lowers the production module and records the test module independently. @@ -227,6 +236,9 @@ "session/log.rs" = 15 "session/menu.rs" = 165 "status.rs" = 199 +# Crate-private named local process used to exercise production sidecar +# validation without exposing a fixture capability constructor. +"test_gateway.rs" = 163 # Grew by grant revocation: `Workspace::revoke` (exact canonical match, # with a literal-key fallback so a deleted root stays revocable; nested # grants independent), the `POST /workspace/revoke` handler with its diff --git a/crates/workshop-server/src/app.rs b/crates/workshop-server/src/app.rs index dd153d19..600a2b60 100644 --- a/crates/workshop-server/src/app.rs +++ b/crates/workshop-server/src/app.rs @@ -170,8 +170,12 @@ 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 gateway_binding = - GatewayBinding::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(); diff --git a/crates/workshop-server/src/fixtures.rs b/crates/workshop-server/src/fixtures.rs new file mode 100644 index 00000000..2d88bcf1 --- /dev/null +++ b/crates/workshop-server/src/fixtures.rs @@ -0,0 +1,59 @@ +//! Integration-test seams that exercise Workshop behavior in-process. + +pub use crate::app::state_with_gateway; +pub use crate::backoff::ReconnectBackoff; +pub use crate::catalog::CatalogBus; +pub use crate::heartbeat::{GatewayHealth, Heartbeat}; +pub use crate::menu::{MenuBus, MenuRefusal}; +pub use crate::protocol::{Activity, Progress, Severity, StatusBarUpdate}; +pub use crate::push::Push; +pub use crate::status::StatusBus; + +#[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() +} + +/// Replaces a configured Gateway fixture without creating a production +/// sidecar capability. +/// +/// # Errors +/// Returns [`crate::GatewayError::Build`] when the fixture client cannot +/// initialize. +#[cfg(feature = "test-fixtures")] +pub fn replace_gateway( + updater: &crate::GatewayUpdater, + base_url: &str, + api_key: &str, +) -> Result<(), crate::GatewayError> { + updater.replace_fixture(base_url, api_key) +} + +/// 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) +} diff --git a/crates/workshop-server/src/gateway.rs b/crates/workshop-server/src/gateway.rs index e7e617af..b294eac7 100644 --- a/crates/workshop-server/src/gateway.rs +++ b/crates/workshop-server/src/gateway.rs @@ -247,14 +247,6 @@ pub fn switch_events(payloads: SsePayloadStream) -> SwitchEventStream { #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum GatewayError { - /// A local-sidecar connection file failed its structural validation. - #[non_exhaustive] - #[error("invalid local gateway connection: {reason}")] - InvalidSidecar { - /// The rejected structural property. - reason: &'static str, - }, - /// The HTTP client could not be built. #[non_exhaustive] #[error("build gateway http client")] diff --git a/crates/workshop-server/src/gateway_binding.rs b/crates/workshop-server/src/gateway_binding.rs index 92ee7295..5b5bb0b2 100644 --- a/crates/workshop-server/src/gateway_binding.rs +++ b/crates/workshop-server/src/gateway_binding.rs @@ -22,12 +22,16 @@ use crate::gateway::{GatewayClient, GatewayError}; 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 { @@ -35,8 +39,11 @@ impl fmt::Debug for GatewaySnapshot { 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() } } @@ -54,7 +61,7 @@ impl GatewaySnapshot { /// The Gateway base URL in this generation. pub(crate) fn base_url(&self) -> &str { - self.client.base_url() + &self.base_url } /// The Gateway bearer in this generation. @@ -88,8 +95,18 @@ impl fmt::Debug for GatewayBinding { impl GatewayBinding { /// Builds generation zero from one endpoint and credential pair. + #[cfg(test)] pub(crate) fn new(base_url: &str, api_key: &str) -> Result { - let snapshot = Arc::new(build_snapshot(base_url, api_key, 0)?); + 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)), @@ -101,12 +118,15 @@ impl GatewayBinding { /// 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)), @@ -132,13 +152,24 @@ impl GatewayBinding { } /// Builds and atomically publishes a replacement, then wakes consumers. + #[cfg(any(test, feature = "test-fixtures"))] 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)?); + let snapshot = Arc::new(build_snapshot(base_url, api_key, generation, identity)?); self.current.store(snapshot); self.changed.send_replace(generation); Ok(()) @@ -152,7 +183,20 @@ impl GatewayBinding { } } -/// A restricted publisher for a replacement local-sidecar connection file. +/// 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, @@ -171,18 +215,28 @@ impl GatewayUpdater { /// long-lived Workshop consumer only after the complete snapshot is live. /// /// # Errors - /// Returns [`GatewayError::InvalidSidecar`] if the file is structurally - /// invalid, or [`GatewayError::Build`] if the replacement HTTP client - /// cannot initialize. + /// Returns [`GatewayError::Build`] if the replacement HTTP client cannot + /// initialize. pub fn replace_sidecar( &self, - file: &shared_sidecar::ConnectionFile, + connection: &shared_sidecar::ValidatedConnection, ) -> Result<(), GatewayError> { - if let Some(reason) = file.validation_error() { - return Err(GatewayError::InvalidSidecar { reason }); - } - self.binding - .replace(&format!("http://127.0.0.1:{}", file.port), &file.api_key) + self.binding.replace_with_identity( + &format!("http://127.0.0.1:{}", connection.port()), + connection.api_key(), + Some(connection.clone()), + ) + } + + /// Replaces the configured Gateway in the crate's integration fixtures + /// without manufacturing a production sidecar capability. + #[cfg(feature = "test-fixtures")] + pub(crate) fn replace_fixture( + &self, + base_url: &str, + api_key: &str, + ) -> Result<(), GatewayError> { + self.binding.replace(base_url, api_key) } } @@ -191,14 +245,18 @@ fn build_snapshot( base_url: &str, api_key: &str, generation: u64, + identity: Option, ) -> Result { let client = GatewayClient::new(base_url, api_key)?; - let model_client = model_client(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, }) } @@ -223,45 +281,4 @@ pub(crate) fn model_client(base_url: &str, api_key: &str) -> Option } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn endpoint_and_credential_replace_as_one_snapshot() { - let binding = - GatewayBinding::new("http://127.0.0.1:54375", "old-key").expect("binding builds"); - let old = binding.snapshot(); - - binding - .replace("http://127.0.0.1:54379", "new-key") - .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!(new.client().base_url, "http://127.0.0.1:54379"); - assert_eq!(new.client().api_key, "new-key"); - assert!(new.generation() > old.generation()); - assert_eq!(binding.generation(), new.generation()); - } - - #[test] - fn updater_rejects_an_invalid_connection_file_before_publication() { - let binding = - GatewayBinding::new("http://127.0.0.1:54375", "old-key").expect("binding builds"); - let before = binding.snapshot(); - let error = binding - .updater() - .replace_sidecar(&shared_sidecar::ConnectionFile { - port: 0, - api_key: "new-key".to_owned(), - pid: 7, - epoch: 0, - version: "test".to_owned(), - started_at: String::new(), - }) - .expect_err("an invalid connection file is refused"); - assert!(matches!(error, GatewayError::InvalidSidecar { .. })); - assert_eq!(binding.generation(), before.generation()); - } -} +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..a37750f3 --- /dev/null +++ b/crates/workshop-server/src/gateway_binding/tests.rs @@ -0,0 +1,94 @@ +use super::*; + +mod atomic; + +fn validated_connection( + gateway: &crate::test_gateway::ValidatedGateway, + api_key: &str, + epoch: u64, + started_at: &str, +) -> shared_sidecar::ValidatedConnection { + gateway.validate(api_key, epoch, started_at) +} + +#[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 gateway = crate::test_gateway::ValidatedGateway::spawn("new-key"); + let port = gateway.port(); + let validated = + validated_connection(&gateway, "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 gateway = crate::test_gateway::ValidatedGateway::spawn("stable-key"); + let first = validated_connection( + &gateway, + "stable-key", + 1_778_000_001, + "2026-09-07T18:00:01Z", + ); + let replacement = validated_connection( + &gateway, + "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_binding/tests/atomic.rs b/crates/workshop-server/src/gateway_binding/tests/atomic.rs new file mode 100644 index 00000000..b3cad656 --- /dev/null +++ b/crates/workshop-server/src/gateway_binding/tests/atomic.rs @@ -0,0 +1,80 @@ +use super::*; + +#[test] +fn synchronized_reads_never_observe_a_torn_replacement_snapshot() { + let gateway = crate::test_gateway::ValidatedGateway::spawn("new-key"); + let validated = + validated_connection(&gateway, "new-key", 1_778_000_001, "2026-09-07T18:00:01Z"); + let new_url = format!("http://127.0.0.1:{}", gateway.port()); + let binding = GatewayBinding::new("http://127.0.0.1:54375", "old-key").expect("binding builds"); + let updater = binding.updater(); + let (target_tx, target_rx) = std::sync::mpsc::sync_channel(0); + let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(0); + let (observed_tx, observed_rx) = std::sync::mpsc::sync_channel(0); + + let samples = std::thread::scope(|scope| { + let reader_binding = binding.clone(); + let reader_validated = validated.clone(); + let reader_url = new_url.clone(); + let reader = scope.spawn(move || { + let mut samples = 0_usize; + while let Ok(target_generation) = target_rx.recv() { + ready_tx.send(()).expect("the publisher remains live"); + loop { + let snapshot = reader_binding.snapshot(); + samples += 1; + if snapshot.generation() % 2 == 0 { + assert_eq!(snapshot.client().base_url, "http://127.0.0.1:54375"); + assert_eq!(snapshot.client().api_key, "old-key"); + assert_eq!(snapshot.base_url(), "http://127.0.0.1:54375"); + assert_eq!(snapshot.api_key(), "old-key"); + assert!(snapshot.identity.is_none()); + assert!( + format!("{:?}", snapshot.model_client().expect("old model client")) + .contains("http://127.0.0.1:54375/v1") + ); + } else { + assert_eq!(snapshot.client().base_url, reader_url); + assert_eq!(snapshot.client().api_key, "new-key"); + assert_eq!(snapshot.base_url(), reader_url); + assert_eq!(snapshot.api_key(), "new-key"); + assert_eq!(snapshot.identity.as_ref(), Some(&reader_validated)); + assert!( + format!("{:?}", snapshot.model_client().expect("new model client")) + .contains(&format!("{reader_url}/v1")) + ); + } + if snapshot.generation() >= target_generation { + break; + } + std::thread::yield_now(); + } + observed_tx + .send(()) + .expect("the publisher observes the sample"); + } + samples + }); + + for generation in 1_u64..=256 { + target_tx.send(generation).expect("the reader remains live"); + ready_rx.recv().expect("the reader begins sampling"); + if generation % 2 == 0 { + binding + .replace("http://127.0.0.1:54375", "old-key") + .expect("the configured snapshot republishes"); + } else { + updater + .replace_sidecar(&validated) + .expect("the sidecar snapshot publishes"); + } + observed_rx + .recv() + .expect("the reader observes the published generation"); + } + drop(target_tx); + reader.join().expect("the reader does not panic") + }); + + assert!(samples >= 256, "every synchronized publication is sampled"); +} diff --git a/crates/workshop-server/src/lib.rs b/crates/workshop-server/src/lib.rs index a9b71bc6..f96cff8f 100644 --- a/crates/workshop-server/src/lib.rs +++ b/crates/workshop-server/src/lib.rs @@ -35,6 +35,8 @@ mod serve; mod session; mod session_agents; mod status; +#[cfg(test)] +mod test_gateway; mod workspace; /// Crate-internal test seams, re-exported to the integration-test binary. @@ -46,50 +48,7 @@ mod workspace; /// `test-fixtures` feature, which the crate's own dev-dependency enables /// for every test build while production builds do not. #[doc(hidden)] -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}; - pub use crate::menu::{MenuBus, MenuRefusal}; - pub use crate::protocol::{Activity, Progress, Severity, StatusBarUpdate}; - pub use crate::push::Push; - pub use crate::status::StatusBus; - - #[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 mod fixtures; pub use app::{AppState, DEFAULT_ADDR, StateError, router}; pub use config::{ diff --git a/crates/workshop-server/src/resolve.rs b/crates/workshop-server/src/resolve.rs index 8df37363..9b04f401 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,14 @@ 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 { + 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 +319,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 @@ -328,16 +352,23 @@ mod tests { #[test] fn a_live_connection_file_wins_over_explicit_config() { let dir = tempfile::TempDir::new().expect("tempdir"); - let port = fixture_gateway("file-key"); - live_file(port, "file-key") + let gateway = crate::test_gateway::ValidatedGateway::spawn("file-key"); + let port = gateway.port(); + gateway + .connection_file("file-key", 1_757_000_000, "2026-09-03T12:00:00Z") .write_to(dir.path()) .expect("write"); - let resolved = resolve_with(Some(dir.path()), &explicit_config(), probe_own_image) + let resolved = resolve_with(Some(dir.path()), &explicit_config(), probe) .expect("a live file resolves"); 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 +558,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/test_gateway.rs b/crates/workshop-server/src/test_gateway.rs new file mode 100644 index 00000000..001b3595 --- /dev/null +++ b/crates/workshop-server/src/test_gateway.rs @@ -0,0 +1,163 @@ +//! Crate-private local Gateway process for capability tests. + +use std::io::{Read, Write as _}; +use std::net::{TcpListener, TcpStream}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use shared_sidecar::{ConnectionFile, ValidatedConnection}; + +#[cfg(windows)] +const GATEWAY_EXE_NAME: &str = "promptforge-gateway.exe"; +#[cfg(not(windows))] +const GATEWAY_EXE_NAME: &str = "promptforge-gateway"; + +const CONTROL_ADDRESS_ENV: &str = "PROMPTFORGE_TEST_GATEWAY_CONTROL_ADDRESS"; +const EXPECTED_KEY_ENV: &str = "PROMPTFORGE_TEST_GATEWAY_EXPECTED_KEY"; + +pub(crate) struct ValidatedGateway { + child: Child, + port: u16, + _directory: tempfile::TempDir, +} + +impl ValidatedGateway { + pub(crate) fn spawn(expected_key: &str) -> Self { + let control = TcpListener::bind("127.0.0.1:0").expect("bind fixture control"); + control + .set_nonblocking(true) + .expect("make fixture control nonblocking"); + let directory = tempfile::TempDir::new().expect("create fixture executable directory"); + let executable = directory.path().join(GATEWAY_EXE_NAME); + std::fs::copy( + std::env::current_exe().expect("locate test executable"), + &executable, + ) + .expect("copy test executable under the Gateway image name"); + let mut child = Command::new(&executable) + .args([ + "--exact", + "test_gateway::validated_gateway_fixture_process", + "--ignored", + ]) + .env( + CONTROL_ADDRESS_ENV, + control + .local_addr() + .expect("read fixture control address") + .to_string(), + ) + .env(EXPECTED_KEY_ENV, expected_key) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("start the named Gateway fixture process"); + let deadline = Instant::now() + Duration::from_secs(10); + let mut stream = loop { + match control.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + child.try_wait().expect("observe fixture process").is_none(), + "the named Gateway fixture exited before becoming ready" + ); + assert!( + Instant::now() < deadline, + "the named Gateway fixture did not become ready" + ); + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("accept fixture control connection: {error}"), + } + }; + let mut port = [0_u8; 2]; + stream + .read_exact(&mut port) + .expect("read fixture Gateway port"); + Self { + child, + port: u16::from_be_bytes(port), + _directory: directory, + } + } + + pub(crate) const fn port(&self) -> u16 { + self.port + } + + pub(crate) fn validate( + &self, + api_key: &str, + epoch: u64, + started_at: &str, + ) -> ValidatedConnection { + ValidatedConnection::validate(self.connection_file(api_key, epoch, started_at)) + .expect("the named local Gateway validates") + } + + pub(crate) fn connection_file( + &self, + api_key: &str, + epoch: u64, + started_at: &str, + ) -> ConnectionFile { + ConnectionFile { + port: self.port, + api_key: api_key.to_owned(), + pid: self.child.id(), + epoch, + version: "test".to_owned(), + started_at: started_at.to_owned(), + } + } +} + +impl Drop for ValidatedGateway { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +#[test] +#[ignore = "runs only as a named child process"] +fn validated_gateway_fixture_process() { + let Ok(control_address) = std::env::var(CONTROL_ADDRESS_ENV) else { + return; + }; + let expected_key = + std::env::var(EXPECTED_KEY_ENV).expect("the fixture child receives an expected key"); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind named fixture Gateway"); + let port = listener + .local_addr() + .expect("read named fixture address") + .port(); + TcpStream::connect(control_address) + .and_then(|mut stream| stream.write_all(&port.to_be_bytes())) + .expect("announce named fixture readiness"); + + for stream in listener.incoming() { + let mut stream = stream.expect("accept named fixture request"); + while answer_request(&mut stream, &expected_key) {} + } +} + +fn answer_request(stream: &mut TcpStream, expected_key: &str) -> bool { + let mut buffer = [0_u8; 4096]; + let Ok(read) = stream.read(&mut buffer) else { + return false; + }; + if read == 0 { + return false; + } + 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 { + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}" + } else { + "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n" + }; + stream.write_all(response.as_bytes()).is_ok() +} diff --git a/crates/workshop-server/tests/common/mod.rs b/crates/workshop-server/tests/common/mod.rs index 3ad4c1b0..98e2a02d 100644 --- a/crates/workshop-server/tests/common/mod.rs +++ b/crates/workshop-server/tests/common/mod.rs @@ -85,23 +85,12 @@ impl TestServer { /// 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(), - }; - self.handle + let updater = self + .handle .as_ref() .expect("the handle is held until drop") - .gateway_updater() - .replace_sidecar(&file) + .gateway_updater(); + workshop_server::fixtures::replace_gateway(&updater, gateway_base_url, api_key) .expect("the replacement endpoint publishes"); } } diff --git a/crates/workshop-server/tests/it/agents.rs b/crates/workshop-server/tests/it/agents.rs index 4bfb7250..0550a07e 100644 --- a/crates/workshop-server/tests/it/agents.rs +++ b/crates/workshop-server/tests/it/agents.rs @@ -23,7 +23,9 @@ use axum::routing::post; use serde_json::json; use tokio::sync::Notify; -use workshop_server::fixtures::{gateway_updater, state_with_gateway}; +use workshop_server::fixtures::{ + gateway_updater, replace_gateway as replace_fixture_gateway, state_with_gateway, +}; use workshop_server::{ AgentsConfig, AppState, Config, GatewayConfig, ResolvedGateway, ServerConfig, router, }; @@ -177,20 +179,8 @@ async fn spawn_agent_server_for_gateway(base_url: String) -> (String, tempfile:: } /// 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"); - gateway_updater(state) - .replace_sidecar(&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(), - }) +fn replace_gateway(state: &AppState, base_url: &str, _epoch: u64) { + replace_fixture_gateway(&gateway_updater(state), base_url, "replacement-key") .expect("the replacement Gateway publishes"); } diff --git a/crates/workshop-server/tests/it/chat_gate.rs b/crates/workshop-server/tests/it/chat_gate.rs index 51852d3d..394836bf 100644 --- a/crates/workshop-server/tests/it/chat_gate.rs +++ b/crates/workshop-server/tests/it/chat_gate.rs @@ -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::{gateway_updater, state_with_gateway}; +use workshop_server::fixtures::{gateway_updater, replace_gateway, state_with_gateway}; use workshop_server::{ AgentsConfig, AppState, Config, GatewayConfig, InputFrame, InputResponse, ResolvedGateway, ServerConfig, UserInputTool, WaitRegistry, WorkshopObserver, deliver_input_response, router, diff --git a/crates/workshop-server/tests/it/chat_gate/recovery.rs b/crates/workshop-server/tests/it/chat_gate/recovery.rs index 77ac171d..0cf4d46e 100644 --- a/crates/workshop-server/tests/it/chat_gate/recovery.rs +++ b/crates/workshop-server/tests/it/chat_gate/recovery.rs @@ -24,19 +24,11 @@ async fn a_live_chat_session_restarts_on_the_replacement_port_and_key() { }), )) .await; - let port = url::Url::parse(&replacement) - .expect("the replacement URL parses") - .port() - .expect("the replacement URL carries a port"); - gateway_updater(&server.state) - .replace_sidecar(&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(), - }) + replace_gateway( + &gateway_updater(&server.state), + &replacement, + "replacement-key", + ) .expect("the replacement publishes"); let replacement_wait = next_wait_token(&mut socket).await; diff --git a/crates/workshop/Cargo.toml b/crates/workshop/Cargo.toml index 5968d75c..01674f20 100644 --- a/crates/workshop/Cargo.toml +++ b/crates/workshop/Cargo.toml @@ -52,6 +52,7 @@ default = [] [dev-dependencies] tempfile.workspace = true +workshop-server = { workspace = true, features = ["test-fixtures"] } # The test-fixtures feature exposes `resolve_for_test`, so the boot # decision tests run the real liveness gauntlet against the test binary's # own process image. diff --git a/crates/workshop/src/gateway.rs b/crates/workshop/src/gateway.rs index c293d8fa..97044827 100644 --- a/crates/workshop/src/gateway.rs +++ b/crates/workshop/src/gateway.rs @@ -22,7 +22,9 @@ 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. @@ -339,8 +341,10 @@ pub(crate) fn supervise( launch_and_attach(&run_dir, exe) }, |file| { + let validated = ValidatedConnection::validate(file.clone()) + .context("validate the replacement gateway identity")?; updater - .replace_sidecar(file) + .replace_sidecar(&validated) .context("publish the replacement gateway endpoint")?; *slot .lock() @@ -428,7 +432,9 @@ mod tests { use super::*; use std::io::{Read, Write as _}; - use std::net::TcpListener; + use std::net::{TcpListener, TcpStream}; + use std::process::{Child, Command, Stdio}; + use std::sync::Arc; /// The test process's own image name, so the probe's pid and image /// checks pass and the test reaches the liveness probes. @@ -484,29 +490,167 @@ mod tests { /// A fixture gateway: answers `GET /health` with 200 and the key /// probe with 200 only when the bearer matches `expected_key`. - fn fixture_gateway(expected_key: &'static str) -> u16 { + fn fixture_gateway(expected_key: impl Into) -> u16 { + let expected_key = Arc::new(expected_key.into()); 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() { - 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); + let expected_key = Arc::clone(&expected_key); + std::thread::spawn(move || { + loop { + let mut buffer = [0u8; 1024]; + let Ok(read) = stream.read(&mut buffer) else { + break; + }; + if read == 0 { + break; + } + let request = String::from_utf8_lossy(&buffer[..read]); + let accepted = request.starts_with("GET /health ") + || request.lines().any(|line| { + line.eq_ignore_ascii_case(&format!( + "Authorization: Bearer {expected_key}" + )) + }); + 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 } + const CONTROL_ADDRESS_ENV: &str = "PROMPTFORGE_TEST_GATEWAY_CONTROL_ADDRESS"; + const EXPECTED_KEY_ENV: &str = "PROMPTFORGE_TEST_GATEWAY_EXPECTED_KEY"; + + struct NamedGateway { + child: Child, + port: u16, + _directory: tempfile::TempDir, + } + + impl NamedGateway { + fn spawn(expected_key: &str) -> Self { + let control = TcpListener::bind("127.0.0.1:0").expect("bind fixture control"); + control + .set_nonblocking(true) + .expect("make fixture control nonblocking"); + let directory = tempfile::TempDir::new().expect("create fixture executable directory"); + let executable = directory.path().join(GATEWAY_EXE_NAME); + std::fs::copy( + std::env::current_exe().expect("locate test executable"), + &executable, + ) + .expect("copy test executable under the Gateway image name"); + let mut child = Command::new(&executable) + .args([ + "--exact", + "gateway::tests::validated_gateway_fixture_process", + "--ignored", + ]) + .env( + CONTROL_ADDRESS_ENV, + control + .local_addr() + .expect("read fixture control address") + .to_string(), + ) + .env(EXPECTED_KEY_ENV, expected_key) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("start the named Gateway fixture process"); + let deadline = Instant::now() + Duration::from_secs(10); + let mut stream = loop { + match control.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + child.try_wait().expect("observe fixture process").is_none(), + "the named Gateway fixture exited before becoming ready" + ); + assert!( + Instant::now() < deadline, + "the named Gateway fixture did not become ready" + ); + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("accept fixture control connection: {error}"), + } + }; + let mut port = [0_u8; 2]; + stream + .read_exact(&mut port) + .expect("read fixture Gateway port"); + Self { + child, + port: u16::from_be_bytes(port), + _directory: directory, + } + } + + fn connection_file(&self, api_key: &str, epoch: u64, started_at: &str) -> ConnectionFile { + ConnectionFile { + port: self.port, + api_key: api_key.to_owned(), + pid: self.child.id(), + epoch, + version: "test".to_owned(), + started_at: started_at.to_owned(), + } + } + } + + impl Drop for NamedGateway { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } + + #[test] + #[ignore = "runs only as a named child process"] + fn validated_gateway_fixture_process() { + let Ok(control_address) = std::env::var(CONTROL_ADDRESS_ENV) else { + return; + }; + let expected_key = + std::env::var(EXPECTED_KEY_ENV).expect("the fixture child receives an expected key"); + let port = fixture_gateway(expected_key); + TcpStream::connect(control_address) + .and_then(|mut stream| stream.write_all(&port.to_be_bytes())) + .expect("announce named fixture readiness"); + loop { + std::thread::park(); + } + } + + fn get(url: &str, path: &str) -> String { + let url = url::Url::parse(url).expect("the Workshop URL parses"); + let host = url.host_str().expect("the Workshop URL has a host"); + let port = url.port().expect("the Workshop URL has a port"); + let mut stream = TcpStream::connect((host, port)).expect("connect to Workshop"); + write!( + stream, + "GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n" + ) + .expect("send Workshop request"); + let mut response = String::new(); + stream + .read_to_string(&mut response) + .expect("read Workshop response"); + response + } + /// An executable directory, with or without the sibling gateway. fn exe_dir(with_gateway: bool) -> (tempfile::TempDir, PathBuf) { let dir = tempfile::TempDir::new().expect("tempdir"); @@ -724,13 +868,30 @@ mod tests { 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 gateway = NamedGateway::spawn("new-key"); + let original = gateway.connection_file("old-key", 1_757_000_000, "2026-09-03T12:00:00Z"); + let replacement = gateway.connection_file("new-key", 1_757_000_001, "2026-09-03T12:00:01Z"); + let state_dir = tempfile::TempDir::new().expect("create Workshop state directory"); + let server = workshop_server::fixtures::spawn(workshop_server::Config { + gateway: workshop_server::GatewayConfig { + base_url: format!("http://127.0.0.1:{}", original.port), + api_key: original.api_key.clone(), + }, + server: workshop_server::ServerConfig { + bind: "127.0.0.1:0".to_owned(), + open_browser: false, + state_dir: state_dir.path().to_owned(), + }, + agents: workshop_server::AgentsConfig::default(), + }) + .expect("spawn Workshop against the original same-port key"); + let updater = server.gateway_updater(); + let publish_replacement = |file: &ConnectionFile| -> anyhow::Result<()> { + let validated = ValidatedConnection::validate(file.clone()) + .context("validate the named local Gateway")?; + updater + .replace_sidecar(&validated) + .context("publish through the production updater") }; let elapsed = Cell::new(Duration::ZERO); let recoveries = Cell::new(0_u8); @@ -753,8 +914,9 @@ mod tests { Ok(replacement.clone()) }, |file| { + publish_replacement(file)?; published.borrow_mut().push(file.clone()); - Ok(()) + Ok::<(), anyhow::Error>(()) }, |delay| { assert!( @@ -786,6 +948,12 @@ mod tests { original.api_key, "a configured key edit propagates with the replacement identity" ); + let response = get(server.url(), "/gateway/api/admin/status"); + assert!( + response.starts_with("HTTP/1.1 200"), + "the real publisher replaces the bearer on the reused port: {response}" + ); + server.shutdown().expect("stop the Workshop fixture"); } #[test] diff --git a/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index a55bc547..f9d2a643 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -500,7 +500,7 @@ isProject: false - 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 +### Step 25: Require capability-based Gateway publication [completed] - 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 1932d6de..764f263d 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -213,3 +213,5 @@ N84 | observation | clone-block @ crates/shared-sidecar/src/validated.rs::fixtur 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 +N88 | observation | clone-block @ crates/workshop-server/src/test_gateway.rs::ValidatedGateway::spawn: duplicates named child Gateway setup in Workshop supervision tests | Require capability-based Gateway publication +N89 | observation | clone-block @ crates/workshop/src/gateway.rs::tests::NamedGateway::spawn: duplicates named child Gateway setup in workshop-server capability tests | Require capability-based Gateway publication From d13f8264e2cd28f64b5af789f12d4c292c9698c3 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 19:59:34 -0700 Subject: [PATCH 84/86] Route quit through the current Gateway snapshot Make quit load the current validated local Gateway generation from the same immutable snapshot as HTTP and model clients, then authenticate shutdown with that generation's bearer. This removes the desktop shell's duplicate mutable identity store, excludes configured LAN Gateways from shutdown authority, and preserves menu labels, accelerators, error reporting, and unconditional exit. - `GatewaySlot` is removed. `GatewayUpdater` loads `snapshot.identity` once and delegates the resulting `ValidatedConnection` to `request_shutdown`. - `PublicationOrder` synchronizes quit before and after `replace_sidecar`; each case proves that only the identity and bearer current at snapshot load receive the request. - `module-ceilings.toml` ratchets the shutdown implementation and race suite. `test_gateway.rs` reports accepted shutdown requests without exposing a capability constructor. - `request_shutdown` returns `Ok(false)` for configured Gateway snapshots with no local identity, so LAN endpoints receive no shutdown request. `install` and `handle_event` retain their existing menu selection and exit behavior. Design: new facade @ crates/workshop-server/src/gateway_binding.rs::GatewayUpdater boundary: pub Design: extends surface-growth @ crates/workshop-server/src/gateway_binding.rs::GatewayUpdater boundary: pub Design: removes shared-mutable-state @ crates/workshop/src/main.rs::GatewaySlot Design: new oversized-unit @ crates/workshop-server/src/gateway_binding/tests/shutdown.rs Design: new oversized-unit @ crates/workshop-server/src/gateway_binding/tests/shutdown.rs::assert_shutdown_target deps: PublicationOrder Design: new dispatch-on-tag @ crates/workshop-server/src/gateway_binding/tests/shutdown.rs::assert_shutdown_target deps: PublicationOrder Design: extends oversized-unit @ crates/workshop-server/src/test_gateway.rs Plan: vibe/2026-09-07-1-promptforge-debt.md --- crates/shared-sidecar/README.md | 1 + crates/shared-sidecar/src/lib.rs | 5 +- crates/shared-sidecar/src/shutdown.rs | 43 ++++--- crates/shared-sidecar/src/validated.rs | 4 + crates/workshop-server/module-ceilings.toml | 9 +- crates/workshop-server/src/app.rs | 2 +- crates/workshop-server/src/gateway_binding.rs | 8 +- .../src/gateway_binding/shutdown.rs | 24 ++++ .../src/gateway_binding/tests.rs | 1 + .../src/gateway_binding/tests/shutdown.rs | 105 ++++++++++++++++++ crates/workshop-server/src/serve.rs | 5 +- crates/workshop-server/src/test_gateway.rs | 48 ++++++-- crates/workshop/src/gateway.rs | 20 ++-- crates/workshop/src/main.rs | 21 +--- crates/workshop/src/menu.rs | 34 +++--- vibe/2026-09-07-1-promptforge-debt.md | 2 +- vibe/archdoc-next.md | 4 + 17 files changed, 259 insertions(+), 77 deletions(-) create mode 100644 crates/workshop-server/src/gateway_binding/shutdown.rs create mode 100644 crates/workshop-server/src/gateway_binding/tests/shutdown.rs diff --git a/crates/shared-sidecar/README.md b/crates/shared-sidecar/README.md index 24d885e2..515853c8 100644 --- a/crates/shared-sidecar/README.md +++ b/crates/shared-sidecar/README.md @@ -10,6 +10,7 @@ The shared sidecar discovery seam for PromptForge: the `gateway.json` connection - `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. +- `request_shutdown` - post the authenticated shutdown request for a validated local Gateway capability. - `wait_for_health` - poll `GET /health` until it answers 200 or the timeout elapses. - `run_dir` / `default_run_dir` / `connection_file_path` / `lock_file_path` - the path layout under `/.promptforge/run`. diff --git a/crates/shared-sidecar/src/lib.rs b/crates/shared-sidecar/src/lib.rs index aa0d40b3..b688763c 100644 --- a/crates/shared-sidecar/src/lib.rs +++ b/crates/shared-sidecar/src/lib.rs @@ -22,8 +22,9 @@ //! 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 -//! posts the file's bearer key to `POST /shutdown`. +//! 4. A reader holding a [`ValidatedConnection`] asks the gateway to exit +//! with [`request_shutdown`], which posts its bearer key to +//! `POST /shutdown`. //! //! URLs normalize to a literal `127.0.0.1`, never `localhost`, and probes //! send the bound address as the `Host` header, matching the gateway's diff --git a/crates/shared-sidecar/src/shutdown.rs b/crates/shared-sidecar/src/shutdown.rs index 5fccc101..1ec2d36f 100644 --- a/crates/shared-sidecar/src/shutdown.rs +++ b/crates/shared-sidecar/src/shutdown.rs @@ -1,13 +1,13 @@ //! The `POST /shutdown` request: how a reader asks the gateway to exit. //! //! The workshop shell's quit-everything menu item is the caller: it posts -//! the connection file's bearer key to the gateway's shutdown route, which -//! answers `202 Accepted` and drains. Raw HTTP/1.0 over `TcpStream` like -//! the health probe, matching the crate's dependency diet - no HTTP -//! client. +//! the current validated connection's bearer key to the gateway's shutdown +//! route, which answers `202 Accepted` and drains. Raw HTTP/1.0 over +//! `TcpStream` like the health probe, matching the crate's dependency diet - +//! no HTTP client. -use crate::ConnectionFile; use crate::health::{self, ProbeError}; +use crate::{ConnectionFile, ValidatedConnection}; /// The shutdown route's path on the gateway. const SHUTDOWN_PATH: &str = "/shutdown"; @@ -44,9 +44,9 @@ impl From for ShutdownError { } } -/// Posts `POST /shutdown` to the gateway `file` names, presenting the -/// file's bearer key. A 2xx answer means the gateway accepted and is -/// draining; the caller exits without waiting for the process to die. +/// Posts `POST /shutdown` to the validated local Gateway, presenting its +/// bearer key. A 2xx answer means the Gateway accepted and is draining; +/// the caller exits without waiting for the process to die. /// /// # Errors /// Returns [`ShutdownError::Io`] when the gateway cannot be connected or @@ -55,12 +55,23 @@ impl From for ShutdownError { /// /// # Examples /// ```no_run -/// # let dir = tempfile::tempdir()?; -/// # let file = shared_sidecar::ConnectionFile::read(dir.path())?.expect("a live file"); -/// shared_sidecar::request_shutdown(&file)?; +/// # let file = shared_sidecar::ConnectionFile { +/// # port: 8081, +/// # api_key: "secret".into(), +/// # pid: 42, +/// # epoch: 1, +/// # version: "0.2.0".into(), +/// # started_at: "2026-09-07T00:00:00Z".into(), +/// # }; +/// let connection = shared_sidecar::ValidatedConnection::validate(file)?; +/// shared_sidecar::request_shutdown(&connection)?; /// # Ok::<(), Box>(()) /// ``` -pub fn request_shutdown(file: &ConnectionFile) -> Result<(), ShutdownError> { +pub fn request_shutdown(connection: &ValidatedConnection) -> Result<(), ShutdownError> { + request_shutdown_file(connection.connection_file()) +} + +fn request_shutdown_file(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 status = head @@ -118,7 +129,7 @@ mod tests { fn an_accepted_shutdown_posts_the_route_with_the_files_key() { let (port, received) = fixture_gateway(b"HTTP/1.1 202 Accepted\r\nContent-Length: 0\r\n\r\n"); - request_shutdown(&file(port)).expect("the gateway accepted"); + request_shutdown_file(&file(port)).expect("the gateway accepted"); let request = received .recv_timeout(Duration::from_secs(5)) .expect("the request arrived"); @@ -140,7 +151,7 @@ mod tests { fn a_refused_shutdown_is_rejected() { let (port, _received) = fixture_gateway(b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n"); - let error = request_shutdown(&file(port)).expect_err("a 401 is a refusal"); + let error = request_shutdown_file(&file(port)).expect_err("a 401 is a refusal"); assert!( matches!(error, ShutdownError::Rejected { .. }), "a non-2xx answer is a rejection: {error}" @@ -156,7 +167,7 @@ mod tests { let mut connection = file(port); connection.api_key = secret.to_owned(); - let error = request_shutdown(&connection).expect_err("a 401 is a refusal"); + let error = request_shutdown_file(&connection).expect_err("a 401 is a refusal"); assert!( !format!("{error:?} {error}").contains(secret), "bearer values never enter error diagnostics" @@ -166,7 +177,7 @@ mod tests { #[test] fn a_dead_gateway_is_an_io_error() { // Port 1 is never listening, so the connect fails fast. - let error = request_shutdown(&file(1)).expect_err("a dead port cannot answer"); + let error = request_shutdown_file(&file(1)).expect_err("a dead port cannot answer"); assert!( matches!(error, ShutdownError::Io { .. }), "an undelivered request is an I/O error: {error}" diff --git a/crates/shared-sidecar/src/validated.rs b/crates/shared-sidecar/src/validated.rs index ab1691a3..5d96b1cf 100644 --- a/crates/shared-sidecar/src/validated.rs +++ b/crates/shared-sidecar/src/validated.rs @@ -158,6 +158,10 @@ impl ValidatedConnection { &self.connection.api_key } + pub(crate) fn connection_file(&self) -> &ConnectionFile { + &self.connection + } + pub(crate) fn into_connection_file(self) -> ConnectionFile { self.connection } diff --git a/crates/workshop-server/module-ceilings.toml b/crates/workshop-server/module-ceilings.toml index bd75ef50..51e70e77 100644 --- a/crates/workshop-server/module-ceilings.toml +++ b/crates/workshop-server/module-ceilings.toml @@ -73,11 +73,15 @@ # New module: one atomically replaceable Gateway URL and bearer generation # shared by HTTP, Realtime, progress, heartbeat, and agent model clients. "gateway_binding.rs" = 267 +# Current validated-snapshot shutdown authority for the embedding host. +"gateway_binding/shutdown.rs" = 24 # Capability-only publication and coherent immutable snapshot coverage. "gateway_binding/tests.rs" = 95 # Deterministically synchronized publisher and reader coverage for complete # immutable Gateway generations. "gateway_binding/tests/atomic.rs" = 80 +# Replacement-versus-quit coherence and configured-LAN denial coverage. +"gateway_binding/tests/shutdown.rs" = 77 # Split from gateway.rs: fixed-target authenticated WebSocket connections # for the Realtime relay path. "gateway/socket.rs" = 94 @@ -237,8 +241,9 @@ "session/menu.rs" = 165 "status.rs" = 199 # Crate-private named local process used to exercise production sidecar -# validation without exposing a fixture capability constructor. -"test_gateway.rs" = 163 +# validation and observe authenticated shutdown without exposing a fixture +# capability constructor. +"test_gateway.rs" = 197 # Grew by grant revocation: `Workspace::revoke` (exact canonical match, # with a literal-key fallback so a deleted root stays revocable; nested # grants independent), the `POST /workspace/revoke` handler with its diff --git a/crates/workshop-server/src/app.rs b/crates/workshop-server/src/app.rs index 600a2b60..4606018a 100644 --- a/crates/workshop-server/src/app.rs +++ b/crates/workshop-server/src/app.rs @@ -93,7 +93,7 @@ impl AppState { &self.gateway } - /// The restricted local-sidecar replacement handle for an embedding host. + /// Restricted local-Gateway authority for an embedding host. pub(crate) fn gateway_updater(&self) -> GatewayUpdater { self.gateway.updater() } diff --git a/crates/workshop-server/src/gateway_binding.rs b/crates/workshop-server/src/gateway_binding.rs index 5b5bb0b2..b0dc3957 100644 --- a/crates/workshop-server/src/gateway_binding.rs +++ b/crates/workshop-server/src/gateway_binding.rs @@ -6,6 +6,8 @@ //! atomic store, then notifies long-lived tasks to reconnect. Explicitly //! configured endpoints never receive an updater from the desktop shell. +mod shutdown; + use std::fmt; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; @@ -183,9 +185,11 @@ impl GatewayBinding { } } -/// A restricted publisher for a replacement validated local sidecar. +/// Restricted local-Gateway authority for an embedding desktop host. /// -/// Raw connection files cannot cross this publication boundary: +/// Replacements accept only validated capabilities, and shutdown reads the +/// same current immutable snapshot as every Workshop consumer. Raw connection +/// files cannot cross the publication boundary: /// /// ```compile_fail /// use shared_sidecar::ConnectionFile; diff --git a/crates/workshop-server/src/gateway_binding/shutdown.rs b/crates/workshop-server/src/gateway_binding/shutdown.rs new file mode 100644 index 00000000..4dc0d51a --- /dev/null +++ b/crates/workshop-server/src/gateway_binding/shutdown.rs @@ -0,0 +1,24 @@ +//! Shutdown authority derived from the current Gateway snapshot. + +use super::GatewayUpdater; + +impl GatewayUpdater { + /// Requests shutdown from the validated local Gateway in the current + /// consumer snapshot. + /// + /// Returns `Ok(false)` without sending a request when the current Gateway + /// came from explicit configuration and therefore grants no local shutdown + /// authority. + /// + /// # Errors + /// Returns [`shared_sidecar::ShutdownError`] when the current local + /// Gateway refuses the request or cannot be reached. + pub fn request_shutdown(&self) -> Result { + let snapshot = self.binding.snapshot(); + let Some(identity) = snapshot.identity.as_ref() else { + return Ok(false); + }; + shared_sidecar::request_shutdown(identity)?; + Ok(true) + } +} diff --git a/crates/workshop-server/src/gateway_binding/tests.rs b/crates/workshop-server/src/gateway_binding/tests.rs index a37750f3..82b68b6e 100644 --- a/crates/workshop-server/src/gateway_binding/tests.rs +++ b/crates/workshop-server/src/gateway_binding/tests.rs @@ -1,6 +1,7 @@ use super::*; mod atomic; +mod shutdown; fn validated_connection( gateway: &crate::test_gateway::ValidatedGateway, diff --git a/crates/workshop-server/src/gateway_binding/tests/shutdown.rs b/crates/workshop-server/src/gateway_binding/tests/shutdown.rs new file mode 100644 index 00000000..b00932e8 --- /dev/null +++ b/crates/workshop-server/src/gateway_binding/tests/shutdown.rs @@ -0,0 +1,105 @@ +use super::*; + +use std::{sync::mpsc, time::Duration}; + +#[derive(Clone, Copy)] +enum PublicationOrder { + QuitFirst, + ReplacementFirst, +} + +fn assert_shutdown_target(order: PublicationOrder) { + let mut original_gateway = crate::test_gateway::ValidatedGateway::spawn("original-key"); + let mut replacement_gateway = crate::test_gateway::ValidatedGateway::spawn("replacement-key"); + let original = validated_connection( + &original_gateway, + "original-key", + 1_778_000_001, + "2026-09-07T18:00:01Z", + ); + let replacement = validated_connection( + &replacement_gateway, + "replacement-key", + 1_778_000_002, + "2026-09-07T18:00:02Z", + ); + let binding = GatewayBinding::new_with_identity( + &format!("http://127.0.0.1:{}", original.port()), + original.api_key(), + Some(original.clone()), + ) + .expect("binding builds"); + let updater = binding.updater(); + let (publish, await_publish) = mpsc::sync_channel(0); + let (completed, await_completion) = mpsc::sync_channel(0); + let (quit, await_quit) = mpsc::sync_channel(0); + + let shutdown_requested = std::thread::scope(|scope| { + let publish_updater = updater.clone(); + let worker = scope.spawn(move || { + await_publish.recv().expect("publication is released"); + publish_updater + .replace_sidecar(&replacement) + .expect("the replacement publishes"); + completed.send(()).expect("publication is observed"); + }); + let shutdown_updater = updater; + let shutdown = scope.spawn(move || { + await_quit.recv().expect("quit is released"); + shutdown_updater + .request_shutdown() + .expect("one coherent generation accepts shutdown") + }); + + let result = match order { + PublicationOrder::QuitFirst => { + quit.send(()).expect("release quit"); + let result = shutdown.join().expect("shutdown does not panic"); + publish.send(()).expect("release publication"); + await_completion.recv().expect("observe publication"); + result + } + PublicationOrder::ReplacementFirst => { + publish.send(()).expect("release publication"); + await_completion.recv().expect("observe publication"); + quit.send(()).expect("release quit"); + shutdown.join().expect("shutdown does not panic") + } + }; + worker.join().expect("publisher does not panic"); + result + }); + + let original_hit = original_gateway.received_shutdown(Duration::from_millis(250)); + let replacement_hit = replacement_gateway.received_shutdown(Duration::from_millis(250)); + let expect_original = matches!(order, PublicationOrder::QuitFirst); + assert_eq!( + (shutdown_requested, original_hit, replacement_hit), + (true, expect_original, !expect_original), + "quit targets only the identity current at its controlled snapshot load" + ); +} + +#[test] +fn quit_before_replacement_publication_targets_original_identity() { + assert_shutdown_target(PublicationOrder::QuitFirst); +} + +#[test] +fn quit_after_replacement_publication_targets_replacement_identity() { + assert_shutdown_target(PublicationOrder::ReplacementFirst); +} + +#[test] +fn configured_gateway_has_no_shutdown_authority() { + let binding = + GatewayBinding::new("http://192.0.2.10:8080", "configured-key").expect("binding builds"); + + assert!( + !binding + .updater() + .request_shutdown() + .expect("a configured Gateway is an intentional no-op"), + "the absence of validated local identity denies shutdown authority" + ); +} diff --git a/crates/workshop-server/src/serve.rs b/crates/workshop-server/src/serve.rs index c2419866..a66bcbcb 100644 --- a/crates/workshop-server/src/serve.rs +++ b/crates/workshop-server/src/serve.rs @@ -67,8 +67,9 @@ 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. + /// Returns the restricted local-Gateway handle used by an embedding + /// desktop host to replace a sidecar atomically and request shutdown from + /// the current validated generation. #[must_use] pub fn gateway_updater(&self) -> GatewayUpdater { self.gateway.clone() diff --git a/crates/workshop-server/src/test_gateway.rs b/crates/workshop-server/src/test_gateway.rs index 001b3595..d9dfe535 100644 --- a/crates/workshop-server/src/test_gateway.rs +++ b/crates/workshop-server/src/test_gateway.rs @@ -18,6 +18,7 @@ const EXPECTED_KEY_ENV: &str = "PROMPTFORGE_TEST_GATEWAY_EXPECTED_KEY"; pub(crate) struct ValidatedGateway { child: Child, port: u16, + control: TcpStream, _directory: tempfile::TempDir, } @@ -78,6 +79,7 @@ impl ValidatedGateway { Self { child, port: u16::from_be_bytes(port), + control: stream, _directory: directory, } } @@ -111,6 +113,28 @@ impl ValidatedGateway { started_at: started_at.to_owned(), } } + + pub(crate) fn received_shutdown(&mut self, timeout: Duration) -> bool { + self.control + .set_read_timeout(Some(timeout)) + .expect("set fixture control timeout"); + let mut marker = [0_u8; 1]; + match self.control.read_exact(&mut marker) { + Ok(()) => { + assert_eq!(marker, [1], "the fixture reports only shutdown requests"); + true + } + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + false + } + Err(error) => panic!("read fixture shutdown marker: {error}"), + } + } } impl Drop for ValidatedGateway { @@ -133,23 +157,30 @@ fn validated_gateway_fixture_process() { .local_addr() .expect("read named fixture address") .port(); - TcpStream::connect(control_address) - .and_then(|mut stream| stream.write_all(&port.to_be_bytes())) + let mut control = TcpStream::connect(control_address).expect("connect fixture control"); + control + .write_all(&port.to_be_bytes()) .expect("announce named fixture readiness"); for stream in listener.incoming() { let mut stream = stream.expect("accept named fixture request"); - while answer_request(&mut stream, &expected_key) {} + while let Some(shutdown) = answer_request(&mut stream, &expected_key) { + if shutdown { + control + .write_all(&[1]) + .expect("report the accepted shutdown request"); + } + } } } -fn answer_request(stream: &mut TcpStream, expected_key: &str) -> bool { +fn answer_request(stream: &mut TcpStream, expected_key: &str) -> Option { let mut buffer = [0_u8; 4096]; let Ok(read) = stream.read(&mut buffer) else { - return false; + return None; }; if read == 0 { - return false; + return None; } let request = String::from_utf8_lossy(&buffer[..read]); let accepted = request.starts_with("GET /health ") @@ -159,5 +190,8 @@ fn answer_request(stream: &mut TcpStream, expected_key: &str) -> bool { } else { "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n" }; - stream.write_all(response.as_bytes()).is_ok() + stream + .write_all(response.as_bytes()) + .is_ok() + .then(|| accepted && request.starts_with("POST /shutdown ")) } diff --git a/crates/workshop/src/gateway.rs b/crates/workshop/src/gateway.rs index 97044827..f8c04564 100644 --- a/crates/workshop/src/gateway.rs +++ b/crates/workshop/src/gateway.rs @@ -15,7 +15,8 @@ //! //! The shell never reads `gateway.toml`, never deletes `gateway.json`, //! and never kills the gateway on exit; the quit-everything menu item -//! (`crate::menu`) is the only path that stops the gateway. +//! (`crate::menu`) is the only path that stops the Gateway, through the +//! server's current validated binding snapshot. use std::path::{Path, PathBuf}; use std::sync::mpsc; @@ -79,8 +80,8 @@ impl GatewaySupervisor { } } -/// How boot connected the gateway: the fact the quit-everything menu -/// item labels and behaves from. +/// How boot connected the Gateway: the fact the quit-everything menu labels +/// from and the supervisor uses to decide whether it owns local recovery. #[derive(Debug)] pub(crate) enum GatewayAttachment { /// A local sidecar gateway the shell attached to or launched: @@ -91,8 +92,7 @@ pub(crate) enum GatewayAttachment { } impl GatewayAttachment { - /// The connection file of a sidecar attachment, for the - /// quit-everything shutdown post. + /// The initial connection file of a sidecar attachment. pub(crate) fn sidecar_file(&self) -> Option<&ConnectionFile> { match self { Self::Sidecar(file) => Some(file), @@ -308,7 +308,6 @@ fn spawn_detached(exe: &Path) -> std::io::Result<()> { 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); @@ -346,9 +345,6 @@ pub(crate) fn supervise( 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) { @@ -835,19 +831,19 @@ mod tests { } #[test] - fn an_explicit_config_attachment_holds_no_file_for_the_shutdown_post() { + fn an_explicit_config_attachment_holds_no_local_sidecar_file() { let file = live_file(1, "k"); let sidecar = GatewayAttachment::Sidecar(file.clone()); assert_eq!( sidecar.sidecar_file(), Some(&file), - "a sidecar attachment hands the quit item the connection file" + "a sidecar attachment carries its initial local connection file" ); let config = GatewayAttachment::Config; assert_eq!( config.sidecar_file(), None, - "a LAN gateway from explicit config never gets a shutdown post" + "a LAN Gateway from explicit config carries no local sidecar identity" ); } diff --git a/crates/workshop/src/main.rs b/crates/workshop/src/main.rs index 0b27447c..ae832ee6 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::{Arc, Mutex, PoisonError}; +use std::sync::{Mutex, PoisonError}; use std::time::Duration; use anyhow::Context as _; @@ -53,12 +53,6 @@ const HEALTH_TIMEOUT: Duration = Duration::from_secs(15); /// exactly once. type ServerSlot = Mutex>; -/// The managed slot holding the attached or launched sidecar gateway's -/// 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 = Arc>>; - /// The managed local-sidecar supervisor, absent for an explicit LAN Gateway. type GatewaySupervisorSlot = Mutex>; @@ -193,11 +187,10 @@ 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, gateway_slot, supervisor)) => { + Ok((server, url, attachment, 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(gateway_slot); app.manage(GatewaySupervisorSlot::new(supervisor)); app.manage(ServerSlot::new(Some(server))); menu::install(app, attachment.sidecar_file())?; @@ -220,7 +213,6 @@ fn boot() -> anyhow::Result<( ServerHandle, url::Url, gateway::GatewayAttachment, - GatewaySlot, Option, )> { let config = config::load().context("load the workshop configuration")?; @@ -231,12 +223,7 @@ fn boot() -> anyhow::Result<( { Ok(()) => { let url = url::Url::parse(server.url()).context("parse the workshop URL")?; - let gateway_slot = Arc::new(Mutex::new(attachment.sidecar_file().cloned())); - let supervisor = match gateway::supervise( - &attachment, - server.gateway_updater(), - Arc::clone(&gateway_slot), - ) { + let supervisor = match gateway::supervise(&attachment, server.gateway_updater()) { Ok(supervisor) => supervisor, Err(error) => { if let Err(shutdown_error) = server.shutdown() { @@ -245,7 +232,7 @@ fn boot() -> anyhow::Result<( return Err(error.context("supervise the local gateway")); } }; - Ok((server, url, attachment, gateway_slot, supervisor)) + Ok((server, url, attachment, supervisor)) } Err(error) => { if let Err(shutdown_error) = server.shutdown() { diff --git a/crates/workshop/src/menu.rs b/crates/workshop/src/menu.rs index 25729a2c..db249b28 100644 --- a/crates/workshop/src/menu.rs +++ b/crates/workshop/src/menu.rs @@ -2,10 +2,10 @@ //! //! The shell's only menu item quits the app; when boot attached to or //! launched a local sidecar gateway, the item first posts the gateway's -//! `/shutdown` with the connection file's key, so one gesture stops the -//! window, the in-process server, and the gateway. Attached to a LAN -//! gateway through explicit config, the item stops the shell only and -//! says so: a client never stops a shared gateway. +//! `/shutdown` through the server's current validated Gateway snapshot, so +//! one gesture stops the window, the in-process server, and the Gateway. +//! Attached to a LAN Gateway through explicit config, the snapshot grants +//! no shutdown authority, so the item stops the shell only and says so. use std::sync::PoisonError; @@ -13,7 +13,7 @@ use shared_sidecar::ConnectionFile; use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder}; use tauri::{AppHandle, Manager as _, Wry}; -use crate::GatewaySlot; +use crate::ServerSlot; /// The quit item's menu id, matched by the event handler. pub(crate) const QUIT_MENU_ID: &str = "quit-promptforge"; @@ -83,21 +83,25 @@ pub(crate) fn install(app: &tauri::App, sidecar: Option<&ConnectionFile>) -> tau Ok(()) } -/// Handles the quit item: post the sidecar gateway's `/shutdown` when one -/// is attached, then exit the shell (the `RunEvent::Exit` handler stops -/// the in-process server). A refused or undeliverable shutdown request is -/// reported and the shell exits anyway - quit always works, even when -/// the gateway is wedged. +/// Handles the quit item: ask the server's current validated local Gateway +/// snapshot to post `/shutdown`, then exit the shell (the `RunEvent::Exit` +/// handler stops the in-process server). A configured LAN Gateway grants no +/// shutdown authority. A refused or undeliverable request is reported and +/// the shell exits anyway - quit always works, even when the Gateway is +/// wedged. pub(crate) fn handle_event(app: &AppHandle, event: tauri::menu::MenuEvent) { let tauri::menu::MenuEvent { id } = event; if id != QUIT_MENU_ID { return; } - let file = app - .try_state::() - .map(|slot| slot.lock().unwrap_or_else(PoisonError::into_inner).clone()); - if let Some(Some(file)) = file - && let Err(error) = shared_sidecar::request_shutdown(&file) + let gateway = app.try_state::().and_then(|slot| { + slot.lock() + .unwrap_or_else(PoisonError::into_inner) + .as_ref() + .map(workshop_server::ServerHandle::gateway_updater) + }); + if let Some(gateway) = gateway + && let Err(error) = gateway.request_shutdown() { eprintln!( "the gateway did not accept the shutdown request; quitting the shell anyway: {error}" diff --git a/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index f9d2a643..fcf9d23f 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -510,7 +510,7 @@ isProject: false - 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 +### Step 26: Route quit through the authoritative snapshot [completed] - 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 764f263d..2e6d9a36 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -215,3 +215,7 @@ N86 | observation | clone-block @ crates/shared-sidecar/src/lock.rs::fixture_gat 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 N88 | observation | clone-block @ crates/workshop-server/src/test_gateway.rs::ValidatedGateway::spawn: duplicates named child Gateway setup in Workshop supervision tests | Require capability-based Gateway publication N89 | observation | clone-block @ crates/workshop/src/gateway.rs::tests::NamedGateway::spawn: duplicates named child Gateway setup in workshop-server capability tests | Require capability-based Gateway publication +N90 | observation | oversized-unit @ crates/workshop-server/src/gateway_binding/tests/shutdown.rs: adds a 105-line replacement-versus-quit test module | Route quit through the current Gateway snapshot +N91 | observation | oversized-unit @ crates/workshop-server/src/gateway_binding/tests/shutdown.rs::assert_shutdown_target: adds a 78-line deterministic race harness | Route quit through the current Gateway snapshot +N92 | observation | dispatch-on-tag @ crates/workshop-server/src/gateway_binding/tests/shutdown.rs::assert_shutdown_target: selects deterministic publication order through PublicationOrder | Route quit through the current Gateway snapshot +N93 | observation | oversized-unit @ crates/workshop-server/src/test_gateway.rs: extends the named Gateway fixture to 197 lines with shutdown observation | Route quit through the current Gateway snapshot From 0c1e42cbb7ad1ebaaf702ec8265d1389749eb97f Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 20:25:02 -0700 Subject: [PATCH 85/86] Join cancellation-aware sidecar shutdown Make each synchronous sidecar phase observe one cooperative cancellation signal and use one absolute deadline for network work. Cancel and join the supervisor within a finite designed budget, while linearized effect gates prevent cleanup, probes, launch, and publication from starting after cancellation returns. Keep desktop ownership of the local sidecar and leave configured LAN gateways outside shutdown authority. - `CancellationToken` shares one wakeable cancellation state and serializes effect admission against cancellation. `GatewaySupervisor` owns that token and its join handle, and `SUPERVISOR_SHUTDOWN_BUDGET` makes delayed termination visible. - `connect_until`, `RESPONSE_HEAD_LIMIT`, and `RESPONSE_BODY_LIMIT` bound connect, request, response-head, and body work under one per-attempt deadline. Slow-drip and oversized responses cannot renew or exceed that budget. - `resolve_cancellable`, `launch_or_attach_cancellable`, `wait_for_health_cancellable`, and `validate_cancellable` stop retries and gate stale-file removal, process observation, launch, and later phases after cancellation. - `replace_sidecar_cancellable` waits interruptibly for the real replacement lock and publishes only inside the cancellation effect gate, so the authoritative generation does not change after cancellation wins. - `assert_bounded_supervisor_shutdown` uses deterministic phase barriers for resolve, supervision wait, validation, health, launch arbitration, process creation, and publication, then proves joined exit and zero later effects. - `crates/workshop/src/gateway.rs` adds no process-kill path or configured-LAN shutdown authority. Design: new oversized-unit @ crates/shared-sidecar/src/cancellation.rs Design: new shared-mutable-state @ crates/shared-sidecar/src/cancellation.rs::CancellationToken boundary: pub Design: new surface-growth @ crates/shared-sidecar/src/cancellation.rs::CancellationToken boundary: pub Design: new surface-growth @ crates/shared-sidecar/src/error.rs::SidecarError boundary: pub Design: new surface-growth @ crates/shared-sidecar/src/health.rs::HealthError boundary: pub Design: new shared-parameter-cluster @ crates/shared-sidecar/src/health.rs::probe_connection_cancellable deps: &CancellationToken,&str,&str,&str,Duration Design: new shared-parameter-cluster @ crates/shared-sidecar/src/health.rs::probe_connection_with deps: &CancellationToken,&str,&str,&str,Duration,Duration Design: extends shared-parameter-cluster @ crates/shared-sidecar/src/health.rs::probe_connection_once deps: &str,&str,&str,Instant Design: extends flag-parameter @ crates/shared-sidecar/src/health.rs::write_request deps: &mut TcpStream,&str,&str,Instant,Option<&str>,bool Design: new surface-growth @ crates/shared-sidecar/src/health.rs::wait_for_health_cancellable deps: &CancellationToken,&str,Duration boundary: pub Design: new surface-growth @ crates/shared-sidecar/src/lock.rs::launch_or_attach_cancellable deps: &CancellationToken,&Path,Duration boundary: pub Design: new surface-growth @ crates/shared-sidecar/src/stale.rs::resolve_cancellable deps: &CancellationToken,&Path boundary: pub Design: new value-object @ crates/shared-sidecar/src/validated.rs::ValidationError boundary: pub Design: new surface-growth @ crates/shared-sidecar/src/validated.rs::ValidationError boundary: pub Design: extends encapsulated-invariant @ crates/shared-sidecar/src/validated.rs::ValidatedConnection boundary: pub Design: extends facade @ crates/shared-sidecar/src/validated.rs::ValidatedConnection boundary: pub Design: extends surface-growth @ crates/shared-sidecar/src/validated.rs::ValidatedConnection boundary: pub Design: extends oversized-unit @ crates/shared-sidecar/src/validated.rs Design: extends facade @ crates/workshop-server/src/gateway_binding.rs::GatewayBinding Design: extends parameter-object @ crates/workshop-server/src/gateway_binding.rs::GatewayBinding Design: extends shared-mutable-state @ crates/workshop-server/src/gateway_binding.rs::GatewayBinding Design: extends facade @ crates/workshop-server/src/gateway_binding.rs::GatewayUpdater boundary: pub Design: extends surface-growth @ crates/workshop-server/src/gateway_binding.rs::GatewayUpdater boundary: pub Design: new oversized-unit @ crates/workshop-server/src/gateway_binding/publication.rs Design: new shared-mutable-state @ crates/workshop/src/gateway.rs::GatewaySupervisor Design: new shared-parameter-cluster @ crates/workshop/src/gateway.rs::launch_and_attach_cancellable deps: &CancellationToken,&Path,&Path Design: new shared-parameter-cluster @ crates/workshop/src/gateway.rs::wait_for_launched_file_cancellable deps: &CancellationToken,&Path,Duration Pending: N80 - compounds Pending: N81 - compounds Plan: vibe/2026-09-07-1-promptforge-debt.md --- crates/shared-sidecar/README.md | 5 + crates/shared-sidecar/src/cancellation.rs | 149 +++++ crates/shared-sidecar/src/error.rs | 4 + crates/shared-sidecar/src/health.rs | 589 +++++++++++++++- crates/shared-sidecar/src/lib.rs | 9 +- crates/shared-sidecar/src/lock.rs | 150 ++++- crates/shared-sidecar/src/stale.rs | 247 ++++++- crates/shared-sidecar/src/validated.rs | 202 +++++- crates/workshop-server/README.md | 2 +- crates/workshop-server/module-ceilings.toml | 4 + crates/workshop-server/src/gateway_binding.rs | 12 +- .../src/gateway_binding/publication.rs | 85 +++ .../src/gateway_binding/tests.rs | 1 + .../src/gateway_binding/tests/publication.rs | 59 ++ crates/workshop/src/gateway.rs | 627 ++++++++++++++++-- vibe/2026-09-07-1-promptforge-debt.md | 2 +- vibe/archdoc-next.md | 8 +- 17 files changed, 2039 insertions(+), 116 deletions(-) create mode 100644 crates/shared-sidecar/src/cancellation.rs create mode 100644 crates/workshop-server/src/gateway_binding/publication.rs create mode 100644 crates/workshop-server/src/gateway_binding/tests/publication.rs diff --git a/crates/shared-sidecar/README.md b/crates/shared-sidecar/README.md index 515853c8..2dcb84ee 100644 --- a/crates/shared-sidecar/README.md +++ b/crates/shared-sidecar/README.md @@ -12,6 +12,11 @@ The shared sidecar discovery seam for PromptForge: the `gateway.json` connection - `launch_or_attach` - the launch-race lock: the winner launches, losers attach to the winner. - `request_shutdown` - post the authenticated shutdown request for a validated local Gateway capability. - `wait_for_health` - poll `GET /health` until it answers 200 or the timeout elapses. +- `CancellationToken` - a clonable signal that wakes bounded sidecar work and linearizes cleanup or other effects so none begin after cancellation returns. +- `resolve_cancellable` - resolve and validate while allowing cancellation to stop probes and prevent stale-file deletion. +- `launch_or_attach_cancellable` - settle the launch race while allowing cancellation to stop lock waits and prevent a later launch decision. +- `wait_for_health_cancellable` - poll health with cancellation, timed connects, one absolute deadline per attempt, and bounded response framing. +- `ValidationError` and `ValidatedConnection::validate_cancellable` - distinguish cancellation from a stale identity without weakening the validated capability. - `run_dir` / `default_run_dir` / `connection_file_path` / `lock_file_path` - the path layout under `/.promptforge/run`. ## Minimum Rust Version diff --git a/crates/shared-sidecar/src/cancellation.rs b/crates/shared-sidecar/src/cancellation.rs new file mode 100644 index 00000000..5879303d --- /dev/null +++ b/crates/shared-sidecar/src/cancellation.rs @@ -0,0 +1,149 @@ +//! Cooperative cancellation for synchronous sidecar work. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex, PoisonError}; +use std::time::Duration; + +/// A clonable cancellation signal for finite sidecar operations. +/// +/// Cancellation wakes retry waits immediately. [`run_if_active`](Self::run_if_active) +/// also provides a linearization point for effects such as process launch and +/// authoritative publication: cancellation first wakes a cancellable effect +/// already in progress, then waits for it, and no effect starts after +/// cancellation returns. +#[derive(Clone, Debug, Default)] +pub struct CancellationToken { + state: Arc, +} + +#[derive(Debug, Default)] +struct CancellationState { + cancelled: AtomicBool, + effect: Mutex<()>, + waiter: Mutex<()>, + wake: Condvar, +} + +impl CancellationToken { + /// Creates an active cancellation token. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Cancels the token and wakes every retry wait. + pub fn cancel(&self) { + let waiter = self + .state + .waiter + .lock() + .unwrap_or_else(PoisonError::into_inner); + self.state.cancelled.store(true, Ordering::SeqCst); + self.state.wake.notify_all(); + drop(waiter); + + // Wait for an operation that already crossed the effect gate. The + // cancellation flag and wake happen first, so a cancellable operation + // inside the gate can finish instead of deadlocking with `cancel`. + drop( + self.state + .effect + .lock() + .unwrap_or_else(PoisonError::into_inner), + ); + } + + /// Whether cancellation has been requested. + #[must_use] + pub fn is_cancelled(&self) -> bool { + self.state.cancelled.load(Ordering::SeqCst) + } + + /// Waits for cancellation or until `timeout` elapses. + /// + /// Returns `true` when the token is cancelled. + #[must_use] + pub fn wait_timeout(&self, timeout: Duration) -> bool { + if self.is_cancelled() { + return true; + } + let waiter = self + .state + .waiter + .lock() + .unwrap_or_else(PoisonError::into_inner); + if self.is_cancelled() { + return true; + } + let (_waiter, _) = self + .state + .wake + .wait_timeout_while(waiter, timeout, |()| !self.is_cancelled()) + .unwrap_or_else(PoisonError::into_inner); + self.is_cancelled() + } + + /// Runs `operation` only while the token remains active. + /// + /// Cancellation and effect admission are mutually exclusive. Cancellation + /// is signalled before waiting for an admitted operation, so that operation + /// may call [`wait_timeout`](Self::wait_timeout) and stop within its bound. + /// The gate is not reentrant: `operation` must not call `cancel` or + /// `run_if_active` on this token. + pub fn run_if_active(&self, operation: impl FnOnce() -> T) -> Option { + let effect = self + .state + .effect + .lock() + .unwrap_or_else(PoisonError::into_inner); + if self.is_cancelled() { + return None; + } + let result = operation(); + drop(effect); + Some(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::mpsc; + use std::time::Instant; + + #[test] + fn cancellation_wakes_an_operation_inside_the_effect_gate() { + let cancellation = CancellationToken::new(); + let worker_cancellation = cancellation.clone(); + let effects = Arc::new(AtomicUsize::new(0)); + let worker_effects = Arc::clone(&effects); + let (entered, blocked) = mpsc::channel(); + let worker = std::thread::spawn(move || { + worker_cancellation.run_if_active(|| { + entered.send(()).expect("announce the gated operation"); + if !worker_cancellation.wait_timeout(Duration::from_secs(1)) { + worker_effects.fetch_add(1, Ordering::SeqCst); + } + }); + }); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("the operation enters the effect gate"); + + let started = Instant::now(); + cancellation.cancel(); + worker.join().expect("the cancelled operation joins"); + + assert!( + started.elapsed() < Duration::from_millis(250), + "cancellation wakes an operation already inside the effect gate" + ); + assert_eq!( + effects.load(Ordering::SeqCst), + 0, + "the cancelled operation performs no later effect" + ); + } +} diff --git a/crates/shared-sidecar/src/error.rs b/crates/shared-sidecar/src/error.rs index 79d4433f..774759a5 100644 --- a/crates/shared-sidecar/src/error.rs +++ b/crates/shared-sidecar/src/error.rs @@ -10,6 +10,10 @@ use std::time::Duration; #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum SidecarError { + /// The caller cancelled the sidecar operation. + #[error("the sidecar operation was cancelled")] + Cancelled, + /// The run directory could not be created. #[error("create the run directory {path}")] CreateDir { diff --git a/crates/shared-sidecar/src/health.rs b/crates/shared-sidecar/src/health.rs index 7052aa87..7e27345a 100644 --- a/crates/shared-sidecar/src/health.rs +++ b/crates/shared-sidecar/src/health.rs @@ -8,22 +8,33 @@ //! allowlist. use std::io::{Read, Write as _}; -use std::net::TcpStream; +use std::net::{SocketAddr, TcpStream}; use std::time::{Duration, Instant}; +use crate::CancellationToken; + /// Delay between probes while the server comes up. 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); + +/// Per-attempt I/O budget while a supervisor may be cancelled. +const CANCELLABLE_ATTEMPT_TIMEOUT: Duration = Duration::from_millis(100); /// Maximum accepted response head for the two-request validation proof. const RESPONSE_HEAD_LIMIT: usize = 16 * 1024; +/// Maximum body drained between the health and bearer responses. +const RESPONSE_BODY_LIMIT: usize = 64 * 1024; /// A failure of [`wait_for_health`]. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum HealthError { + /// The caller cancelled the health wait. + #[error("the health wait was cancelled")] + Cancelled, + /// The URL is not an `http://` URL. #[error("health probe needs an http:// URL, got {url}")] NotHttp { @@ -83,6 +94,8 @@ pub(crate) enum KeyProbe { pub(crate) enum ConnectionProbe { /// Health answered and the bearer was accepted. Accepted, + /// The caller cancelled validation. + Cancelled, /// Health failed, including a bearer probe that became unreachable. HealthFailed, /// Health answered but the bearer was rejected. @@ -97,17 +110,82 @@ pub(crate) fn probe_connection( bearer_path: &str, bearer: &str, health_budget: Duration, +) -> ConnectionProbe { + probe_connection_with( + address, + bearer_path, + bearer, + health_budget, + &CancellationToken::new(), + ATTEMPT_TIMEOUT, + ) +} + +/// Proves one connection while observing supervisor cancellation. +pub(crate) fn probe_connection_cancellable( + address: &str, + bearer_path: &str, + bearer: &str, + health_budget: Duration, + cancellation: &CancellationToken, +) -> ConnectionProbe { + probe_connection_with( + address, + bearer_path, + bearer, + health_budget, + cancellation, + CANCELLABLE_ATTEMPT_TIMEOUT, + ) +} + +fn probe_connection_with( + address: &str, + bearer_path: &str, + bearer: &str, + health_budget: Duration, + cancellation: &CancellationToken, + attempt_timeout: Duration, +) -> ConnectionProbe { + probe_connection_with_probe( + address, + bearer_path, + bearer, + health_budget, + cancellation, + attempt_timeout, + probe_connection_once, + ) +} + +fn probe_connection_with_probe( + address: &str, + bearer_path: &str, + bearer: &str, + health_budget: Duration, + cancellation: &CancellationToken, + attempt_timeout: Duration, + mut probe: impl FnMut(&str, &str, &str, Instant) -> ConnectionAttempt, ) -> ConnectionProbe { let deadline = Instant::now() + health_budget; loop { - match probe_connection_once(address, bearer_path, bearer) { + let attempt_deadline = (Instant::now() + attempt_timeout).min(deadline); + let Some(attempt) = + cancellation.run_if_active(|| probe(address, bearer_path, bearer, attempt_deadline)) + else { + return ConnectionProbe::Cancelled; + }; + match attempt { 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), + ConnectionAttempt::HealthFailed if cancellation.wait_timeout(RETRY_INTERVAL) => { + return ConnectionProbe::Cancelled; + } + ConnectionAttempt::HealthFailed => {} } } } @@ -124,26 +202,40 @@ enum ConnectionAttempt { /// 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 { +fn probe_connection_once( + address: &str, + bearer_path: &str, + bearer: &str, + deadline: Instant, +) -> ConnectionAttempt { + let Ok(socket) = address.parse::() else { return ConnectionAttempt::HealthFailed; }; - if configure_stream(&stream).is_err() { + let Ok(mut stream) = connect_until(&socket, deadline) else { return ConnectionAttempt::HealthFailed; - } - if write_request(&mut stream, address, "/health", None, false).is_err() { + }; + if write_request(&mut stream, address, "/health", None, false, deadline).is_err() { return ConnectionAttempt::HealthFailed; } - let Ok(health_head) = read_framed_response_head(&mut stream) else { + let Ok(health_head) = read_framed_response_head(&mut stream, deadline) 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() { + if write_request( + &mut stream, + address, + bearer_path, + Some(bearer), + true, + deadline, + ) + .is_err() + { return ConnectionAttempt::ProofInterrupted; } - let Ok(bearer_head) = read_framed_response_head(&mut stream) else { + let Ok(bearer_head) = read_framed_response_head(&mut stream, deadline) else { return ConnectionAttempt::ProofInterrupted; }; if response_status(&bearer_head).is_some_and(|code| (200..300).contains(&code)) { @@ -153,22 +245,42 @@ fn probe_connection_once(address: &str, bearer_path: &str, bearer: &str) -> Conn } } -/// Applies the fixed per-attempt read and write budgets. -fn configure_stream(stream: &TcpStream) -> Result<(), ProbeError> { +/// Connects within the one absolute attempt deadline. +fn connect_until(address: &SocketAddr, deadline: Instant) -> Result { + let timeout = remaining(deadline)?; + TcpStream::connect_timeout(address, timeout).map_err(|source| ProbeError::Io { + operation: "connect", + source, + }) +} + +/// Applies the remaining absolute attempt budget to reads and writes. +fn configure_stream(stream: &TcpStream, deadline: Instant) -> Result<(), ProbeError> { + let timeout = remaining(deadline)?; stream - .set_read_timeout(Some(ATTEMPT_TIMEOUT)) + .set_read_timeout(Some(timeout)) .map_err(|source| ProbeError::Io { operation: "configure the read timeout", source, })?; stream - .set_write_timeout(Some(ATTEMPT_TIMEOUT)) + .set_write_timeout(Some(timeout)) .map_err(|source| ProbeError::Io { operation: "configure the write timeout", source, }) } +fn remaining(deadline: Instant) -> Result { + deadline + .checked_duration_since(Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or_else(|| ProbeError::Io { + operation: "validation deadline elapsed", + source: std::io::Error::from(std::io::ErrorKind::TimedOut), + }) +} + /// Writes one GET request, retaining or closing the connection as directed. fn write_request( stream: &mut TcpStream, @@ -176,6 +288,7 @@ fn write_request( path: &str, bearer: Option<&str>, close: bool, + deadline: Instant, ) -> Result<(), ProbeError> { let connection = if close { "close" } else { "keep-alive" }; let mut request = @@ -186,6 +299,7 @@ fn write_request( request.push_str("\r\n"); } request.push_str("\r\n"); + configure_stream(stream, deadline)?; stream .write_all(request.as_bytes()) .map_err(|source| ProbeError::Io { @@ -196,7 +310,10 @@ fn write_request( /// 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 { +fn read_framed_response_head( + stream: &mut TcpStream, + deadline: Instant, +) -> 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") { @@ -208,10 +325,15 @@ fn read_framed_response_head(stream: &mut TcpStream) -> Result Result".to_owned(), })?; + if content_length > RESPONSE_BODY_LIMIT { + return Err(ProbeError::UnexpectedStatus { + status_line: "".to_owned(), + }); + } let body_already_read = response.len() - header_end; + if body_already_read > content_length { + return Err(ProbeError::UnexpectedStatus { + status_line: "".to_owned(), + }); + } 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()); + configure_stream(stream, deadline)?; let read = stream .read(&mut buffer[..chunk_len]) .map_err(|source| ProbeError::Io { @@ -281,6 +414,60 @@ fn response_status(head: &str) -> Option { /// # Ok::<(), shared_sidecar::HealthError>(()) /// ``` pub fn wait_for_health(base_url: &str, timeout: Duration) -> Result<(), HealthError> { + wait_for_health_cancellable_with( + base_url, + timeout, + &CancellationToken::new(), + ATTEMPT_TIMEOUT, + probe_health_until, + ) +} + +/// Polls `GET {base_url}/health` until it answers 200, the timeout elapses, +/// or the caller cancels the wait. +/// +/// # Errors +/// Returns [`HealthError::Cancelled`] when `cancellation` is signalled, plus +/// the URL and timeout failures documented by [`wait_for_health`]. +pub fn wait_for_health_cancellable( + base_url: &str, + timeout: Duration, + cancellation: &CancellationToken, +) -> Result<(), HealthError> { + wait_for_health_cancellable_with( + base_url, + timeout, + cancellation, + CANCELLABLE_ATTEMPT_TIMEOUT, + probe_health_until, + ) +} + +fn wait_for_health_cancellable_with( + base_url: &str, + timeout: Duration, + cancellation: &CancellationToken, + attempt_timeout: Duration, + mut probe: impl FnMut(&str, Instant) -> Result<(), ProbeError>, +) -> Result<(), HealthError> { + wait_for_health_cancellable_with_start( + base_url, + timeout, + cancellation, + attempt_timeout, + || {}, + |address, deadline| probe(address, deadline), + ) +} + +fn wait_for_health_cancellable_with_start( + base_url: &str, + timeout: Duration, + cancellation: &CancellationToken, + attempt_timeout: Duration, + mut before_probe: impl FnMut(), + mut probe: impl FnMut(&str, Instant) -> Result<(), ProbeError>, +) -> Result<(), HealthError> { let address = base_url .strip_prefix("http://") .ok_or_else(|| HealthError::NotHttp { @@ -288,9 +475,17 @@ pub fn wait_for_health(base_url: &str, timeout: Duration) -> Result<(), HealthEr })?; let deadline = Instant::now() + timeout; loop { - match probe_health(address) { + let attempt_deadline = (Instant::now() + attempt_timeout).min(deadline); + before_probe(); + let Some(attempt) = cancellation.run_if_active(|| probe(address, attempt_deadline)) else { + return Err(HealthError::Cancelled); + }; + match attempt { Ok(()) => return Ok(()), Err(error) => { + if cancellation.is_cancelled() { + return Err(HealthError::Cancelled); + } if Instant::now() >= deadline { return Err(HealthError::Timeout { url: base_url.to_owned(), @@ -298,15 +493,16 @@ pub fn wait_for_health(base_url: &str, timeout: Duration) -> Result<(), HealthEr source: error, }); } - std::thread::sleep(RETRY_INTERVAL); + if cancellation.wait_timeout(RETRY_INTERVAL) { + return Err(HealthError::Cancelled); + } } } } } -/// Issues one `GET /health` and requires a 200 status line. -pub(crate) fn probe_health(address: &str) -> Result<(), ProbeError> { - let head = request_head(address, "GET", "/health", None)?; +fn probe_health_until(address: &str, deadline: Instant) -> Result<(), ProbeError> { + let head = request_head_until(address, "GET", "/health", None, deadline)?; let status_ok = head .split_whitespace() .nth(1) @@ -349,11 +545,33 @@ pub(crate) fn request_head( path: &str, bearer: Option<&str>, ) -> Result { - let mut stream = TcpStream::connect(address).map_err(|source| ProbeError::Io { - operation: "connect", - source, - })?; - configure_stream(&stream)?; + request_head_with_timeout(address, method, path, bearer, ATTEMPT_TIMEOUT) +} + +fn request_head_with_timeout( + address: &str, + method: &str, + path: &str, + bearer: Option<&str>, + timeout: Duration, +) -> Result { + request_head_until(address, method, path, bearer, Instant::now() + timeout) +} + +fn request_head_until( + address: &str, + method: &str, + path: &str, + bearer: Option<&str>, + deadline: Instant, +) -> Result { + let socket = address + .parse::() + .map_err(|source| ProbeError::Io { + operation: "parse the loopback address", + source: std::io::Error::new(std::io::ErrorKind::InvalidInput, source), + })?; + let mut stream = connect_until(&socket, deadline)?; let mut request = format!("{method} {path} HTTP/1.0\r\nHost: {address}\r\n"); if let Some(key) = bearer { request.push_str("Authorization: Bearer "); @@ -361,6 +579,7 @@ pub(crate) fn request_head( request.push_str("\r\n"); } request.push_str("\r\n"); + configure_stream(&stream, deadline)?; stream .write_all(request.as_bytes()) .map_err(|source| ProbeError::Io { @@ -368,6 +587,7 @@ pub(crate) fn request_head( source, })?; let mut buffer = [0u8; 256]; + configure_stream(&stream, deadline)?; let read = stream.read(&mut buffer).map_err(|source| ProbeError::Io { operation: "read the status line", source, @@ -380,7 +600,8 @@ mod tests { use super::*; use std::net::TcpListener; - use std::sync::mpsc; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, mpsc}; /// Answers every connection with a canned response until the test /// stops it. @@ -579,4 +800,310 @@ mod tests { "validation never reconnects after health succeeds" ); } + + #[test] + fn a_real_connect_attempt_obeys_one_absolute_deadline() { + let started = Instant::now(); + let result = probe_connection_once( + "192.0.2.1:9", + "/v1/models", + "key", + Instant::now() + Duration::from_millis(100), + ); + + assert_eq!(result, ConnectionAttempt::HealthFailed); + assert!( + started.elapsed() < Duration::from_millis(500), + "connect_timeout bounds a stalled or unreachable route" + ); + } + + #[test] + fn slow_drip_response_head_cannot_refresh_the_attempt_budget() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind slow-head fixture"); + let address = listener + .local_addr() + .expect("slow-head address") + .to_string(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept slow-head probe"); + let mut request = [0_u8; 1024]; + let _ = stream.read(&mut request); + for byte in b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}" { + if stream.write_all(&[*byte]).is_err() { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + }); + + let started = Instant::now(); + let result = probe_connection_once( + &address, + "/v1/models", + "key", + Instant::now() + CANCELLABLE_ATTEMPT_TIMEOUT, + ); + + assert_eq!(result, ConnectionAttempt::HealthFailed); + assert!( + started.elapsed() < Duration::from_millis(500), + "one absolute deadline bounds a slow-drip response head" + ); + } + + #[test] + fn slow_drip_response_body_cannot_refresh_the_attempt_budget() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind slow-body fixture"); + let address = listener + .local_addr() + .expect("slow-body address") + .to_string(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept slow-body probe"); + let mut request = [0_u8; 1024]; + let _ = stream.read(&mut request); + if stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 16\r\n\r\n") + .is_err() + { + return; + } + for byte in b"0123456789abcdef" { + if stream.write_all(&[*byte]).is_err() { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + }); + + let started = Instant::now(); + let result = probe_connection_once( + &address, + "/v1/models", + "key", + Instant::now() + CANCELLABLE_ATTEMPT_TIMEOUT, + ); + + assert_eq!(result, ConnectionAttempt::HealthFailed); + assert!( + started.elapsed() < Duration::from_millis(500), + "one absolute deadline bounds a slow-drip response body" + ); + } + + #[test] + fn an_unbounded_declared_body_is_rejected_without_draining() { + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", + RESPONSE_BODY_LIMIT + 1 + ); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind oversized-body fixture"); + let address = listener + .local_addr() + .expect("oversized-body address") + .to_string(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept oversized-body probe"); + let mut request = [0_u8; 1024]; + let _ = stream.read(&mut request); + let _ = stream.write_all(response.as_bytes()); + std::thread::sleep(Duration::from_secs(1)); + }); + + let started = Instant::now(); + let result = probe_connection_once( + &address, + "/v1/models", + "key", + Instant::now() + CANCELLABLE_ATTEMPT_TIMEOUT, + ); + + assert_eq!(result, ConnectionAttempt::HealthFailed); + assert!( + started.elapsed() < Duration::from_millis(250), + "an oversized declared body is rejected before its bytes arrive" + ); + } + + #[test] + fn cancellation_joins_a_real_slow_drip_probe_without_retrying() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind cancelled-drip fixture"); + let address = listener + .local_addr() + .expect("cancelled-drip address") + .to_string(); + let connections = Arc::new(AtomicUsize::new(0)); + let server_connections = Arc::clone(&connections); + let (entered, blocked) = mpsc::channel(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept cancelled-drip probe"); + server_connections.fetch_add(1, Ordering::SeqCst); + let mut request = [0_u8; 1024]; + let _ = stream.read(&mut request); + entered.send(()).expect("announce real slow-drip probe"); + for byte in b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}" { + if stream.write_all(&[*byte]).is_err() { + return; + } + std::thread::sleep(Duration::from_millis(20)); + } + }); + let cancellation = CancellationToken::new(); + let worker_cancellation = cancellation.clone(); + let worker = std::thread::spawn(move || { + probe_connection_cancellable( + &address, + "/v1/models", + "key", + Duration::from_secs(30), + &worker_cancellation, + ) + }); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("the real probe begins its slow response"); + + let started = Instant::now(); + cancellation.cancel(); + let result = worker.join().expect("the real probe worker joins"); + + assert_eq!(result, ConnectionProbe::Cancelled); + assert!( + started.elapsed() < Duration::from_millis(500), + "the attempt deadline bounds joined cancellation during real I/O" + ); + assert_eq!( + connections.load(Ordering::SeqCst), + 1, + "cancellation starts no later network probe" + ); + } + + #[test] + fn cancellation_at_validation_probe_start_prevents_the_probe() { + let cancellation = CancellationToken::new(); + let worker_cancellation = cancellation.clone(); + let probe_cancellation = worker_cancellation.clone(); + let probes = Arc::new(AtomicUsize::new(0)); + let worker_probes = Arc::clone(&probes); + let (entered, blocked) = mpsc::channel(); + let worker = std::thread::spawn(move || { + probe_connection_with_probe( + "127.0.0.1:1", + "/v1/models", + "key", + Duration::from_secs(30), + &worker_cancellation, + CANCELLABLE_ATTEMPT_TIMEOUT, + |_, _, _, _| { + entered + .send(()) + .expect("announce validation probe boundary"); + if probe_cancellation.wait_timeout(Duration::from_secs(30)) { + ConnectionAttempt::HealthFailed + } else { + worker_probes.fetch_add(1, Ordering::SeqCst); + ConnectionAttempt::Accepted + } + }, + ) + }); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("validation pauses immediately before probe admission"); + + cancellation.cancel(); + let result = worker.join().expect("validation worker joins"); + + assert_eq!(result, ConnectionProbe::Cancelled); + assert_eq!( + probes.load(Ordering::SeqCst), + 0, + "no validation probe starts after cancellation returns" + ); + } + + #[test] + fn cancellation_at_health_probe_start_prevents_the_probe() { + let cancellation = CancellationToken::new(); + let worker_cancellation = cancellation.clone(); + let probes = Arc::new(AtomicUsize::new(0)); + let worker_probes = Arc::clone(&probes); + let (entered, blocked) = mpsc::channel(); + let (release, released) = mpsc::channel(); + let worker = std::thread::spawn(move || { + wait_for_health_cancellable_with_start( + "http://127.0.0.1:1", + Duration::from_secs(30), + &worker_cancellation, + CANCELLABLE_ATTEMPT_TIMEOUT, + || { + entered.send(()).expect("announce health probe boundary"); + released.recv().expect("release health probe boundary"); + }, + |_, _| { + worker_probes.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ) + }); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("health wait pauses immediately before probe admission"); + + cancellation.cancel(); + release.send(()).expect("release health probe boundary"); + let result = worker.join().expect("health worker joins"); + + assert!(matches!(result, Err(HealthError::Cancelled))); + assert_eq!( + probes.load(Ordering::SeqCst), + 0, + "no health probe starts after cancellation returns" + ); + } + + #[test] + fn cancellation_joins_a_blocked_health_wait_without_another_probe() { + let cancellation = crate::CancellationToken::new(); + let worker_cancellation = cancellation.clone(); + let probes = Arc::new(AtomicUsize::new(0)); + let worker_probes = Arc::clone(&probes); + let (entered, blocked) = mpsc::channel(); + let worker = std::thread::spawn(move || { + wait_for_health_cancellable_with( + "http://127.0.0.1:1", + Duration::from_secs(30), + &worker_cancellation, + CANCELLABLE_ATTEMPT_TIMEOUT, + |_, _| { + worker_probes.fetch_add(1, Ordering::SeqCst); + entered.send(()).expect("announce blocked health probe"); + let _ = worker_cancellation.wait_timeout(Duration::from_secs(30)); + Err(ProbeError::UnexpectedStatus { + status_line: "blocked".to_owned(), + }) + }, + ) + }); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("the health phase blocks deterministically"); + + let started = Instant::now(); + cancellation.cancel(); + let result = worker.join().expect("the health worker joins"); + + assert!( + started.elapsed() < Duration::from_millis(250), + "cancellation bounds the blocked health wait" + ); + assert!(matches!(result, Err(HealthError::Cancelled))); + assert_eq!( + probes.load(Ordering::SeqCst), + 1, + "no probe starts after cancellation" + ); + } } diff --git a/crates/shared-sidecar/src/lib.rs b/crates/shared-sidecar/src/lib.rs index b688763c..2cd82ad7 100644 --- a/crates/shared-sidecar/src/lib.rs +++ b/crates/shared-sidecar/src/lib.rs @@ -31,6 +31,7 @@ //! loopback `Host` allowlist. mod atomic; +mod cancellation; mod error; mod file; mod health; @@ -41,17 +42,19 @@ mod stale; mod sys; mod validated; +pub use crate::cancellation::CancellationToken; pub use crate::error::SidecarError; pub use crate::file::{ConnectionFile, remove_if_mine}; -pub use crate::health::{HealthError, ProbeError, wait_for_health}; -pub use crate::lock::{LaunchDecision, LaunchLock, launch_or_attach}; +pub use crate::health::{HealthError, ProbeError, wait_for_health, wait_for_health_cancellable}; +pub use crate::lock::{LaunchDecision, LaunchLock, launch_or_attach, launch_or_attach_cancellable}; pub use crate::paths::{ CONNECTION_FILE_NAME, LOCK_FILE_NAME, connection_file_path, default_run_dir, lock_file_path, run_dir, }; pub use crate::shutdown::{ShutdownError, request_shutdown}; +pub use crate::stale::resolve_cancellable; #[cfg(feature = "test-fixtures")] #[doc(hidden)] pub use crate::stale::resolve_for_test; pub use crate::stale::{Resolution, StaleReason, is_running, resolve}; -pub use crate::validated::ValidatedConnection; +pub use crate::validated::{ValidatedConnection, ValidationError}; diff --git a/crates/shared-sidecar/src/lock.rs b/crates/shared-sidecar/src/lock.rs index 120847ff..5787bc89 100644 --- a/crates/shared-sidecar/src/lock.rs +++ b/crates/shared-sidecar/src/lock.rs @@ -14,10 +14,10 @@ use std::fs::{File, OpenOptions, TryLockError}; use std::path::Path; use std::time::{Duration, Instant}; -use crate::ConnectionFile; use crate::error::SidecarError; use crate::paths::lock_file_path; use crate::stale::{self, GATEWAY_IMAGE_NAME, Resolution}; +use crate::{CancellationToken, ConnectionFile}; /// Delay between lock retries while a winner finishes its launch. const RETRY_INTERVAL: Duration = Duration::from_millis(25); @@ -60,6 +60,19 @@ pub fn launch_or_attach(run_dir: &Path, timeout: Duration) -> Result Result { + launch_or_attach_named_cancellable(run_dir, GATEWAY_IMAGE_NAME, timeout, cancellation) +} + /// [`launch_or_attach`] against a caller-named process image, so tests /// can run the full liveness gauntlet from a test binary, which is never /// named `promptforge-gateway`. @@ -120,12 +133,100 @@ pub(crate) fn launch_or_attach_named( } } +fn launch_or_attach_named_cancellable( + run_dir: &Path, + image_name: &str, + timeout: Duration, + cancellation: &CancellationToken, +) -> Result { + launch_or_attach_named_cancellable_with(run_dir, image_name, timeout, cancellation, |delay| { + cancellation.wait_timeout(delay) + }) +} + +fn launch_or_attach_named_cancellable_with( + run_dir: &Path, + image_name: &str, + timeout: Duration, + cancellation: &CancellationToken, + mut wait: impl FnMut(Duration) -> bool, +) -> Result { + if cancellation.is_cancelled() { + return Err(SidecarError::Cancelled); + } + std::fs::create_dir_all(run_dir).map_err(|source| SidecarError::CreateDir { + path: run_dir.to_owned(), + source, + })?; + if cancellation.is_cancelled() { + return Err(SidecarError::Cancelled); + } + let lock_path = lock_file_path(run_dir); + let lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .map_err(|source| SidecarError::Lock { + path: lock_path.clone(), + source, + })?; + let deadline = Instant::now() + timeout; + loop { + if cancellation.is_cancelled() { + return Err(SidecarError::Cancelled); + } + match lock.try_lock() { + Ok(()) => { + let resolution = + stale::resolve_named_cancellable(run_dir, image_name, cancellation)?; + if cancellation.is_cancelled() { + return Err(SidecarError::Cancelled); + } + return Ok(match resolution { + Resolution::Attach(file) => LaunchDecision::Attach(file), + Resolution::Absent | Resolution::Stale(_) => { + LaunchDecision::Launch(LaunchLock { _file: lock }) + } + }); + } + Err(TryLockError::WouldBlock) => { + if cancellation.is_cancelled() { + return Err(SidecarError::Cancelled); + } + if let Ok(Some(file)) = ConnectionFile::read(run_dir) + && stale::is_live_cancellable(&file, image_name, cancellation)? + { + if cancellation.is_cancelled() { + return Err(SidecarError::Cancelled); + } + return Ok(LaunchDecision::Attach(file)); + } + if Instant::now() >= deadline { + return Err(SidecarError::LaunchTimeout { timeout }); + } + if wait(RETRY_INTERVAL) { + return Err(SidecarError::Cancelled); + } + } + Err(TryLockError::Error(source)) => { + return Err(SidecarError::Lock { + path: lock_path.clone(), + source, + }); + } + } + } +} + #[cfg(test)] mod tests { use super::*; use std::io::{Read, Write as _}; use std::net::TcpListener; + use std::sync::mpsc; /// The test process's own image name, so the pid and image checks /// pass and the loser reaches the probe path. @@ -276,4 +377,51 @@ mod tests { "the loser reports the timeout: {error}" ); } + + #[test] + fn cancellation_joins_a_blocked_launch_race_without_launching() { + let dir = tempfile::TempDir::new().expect("tempdir"); + let image = own_image_name(); + let LaunchDecision::Launch(winner) = + launch_or_attach_named(dir.path(), &image, Duration::from_secs(5)) + .expect("the first caller wins the lock") + else { + panic!("an empty run dir elects a launcher"); + }; + let cancellation = crate::CancellationToken::new(); + let worker_cancellation = cancellation.clone(); + let run_dir = dir.path().to_owned(); + let worker_image = image.clone(); + let (entered, blocked) = mpsc::channel(); + let worker = std::thread::spawn(move || { + launch_or_attach_named_cancellable_with( + &run_dir, + &worker_image, + Duration::from_secs(30), + &worker_cancellation, + |delay| { + entered.send(()).expect("announce blocked launch race"); + worker_cancellation.wait_timeout(delay) + }, + ) + }); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("the launch-race phase blocks deterministically"); + + let started = Instant::now(); + cancellation.cancel(); + let result = worker.join().expect("the launch-race worker joins"); + + assert!( + started.elapsed() < Duration::from_millis(250), + "cancellation bounds the blocked launch race" + ); + assert!(matches!(result, Err(SidecarError::Cancelled))); + assert!( + !crate::paths::connection_file_path(dir.path()).exists(), + "the cancelled loser never publishes or launches" + ); + drop(winner); + } } diff --git a/crates/shared-sidecar/src/stale.rs b/crates/shared-sidecar/src/stale.rs index c49b298e..01fdeb01 100644 --- a/crates/shared-sidecar/src/stale.rs +++ b/crates/shared-sidecar/src/stale.rs @@ -11,11 +11,11 @@ use std::fs; use std::io; use std::path::Path; -use crate::ConnectionFile; use crate::error::SidecarError; use crate::paths::connection_file_path; pub(crate) use crate::validated::GATEWAY_IMAGE_NAME; -use crate::validated::ValidatedConnection; +use crate::validated::{ValidatedConnection, ValidationError}; +use crate::{CancellationToken, ConnectionFile}; /// What [`resolve`] found in the run directory. #[derive(Debug, Clone, PartialEq, Eq)] @@ -68,6 +68,20 @@ pub fn resolve(run_dir: &Path) -> Result { resolve_named(run_dir, GATEWAY_IMAGE_NAME) } +/// Resolves the connection file while observing caller cancellation. +/// +/// Cancellation never classifies or deletes the current file. +/// +/// # Errors +/// Returns [`SidecarError::Cancelled`] when cancellation wins, plus the +/// read and remove failures documented by [`resolve`]. +pub fn resolve_cancellable( + run_dir: &Path, + cancellation: &CancellationToken, +) -> Result { + resolve_named_cancellable(run_dir, GATEWAY_IMAGE_NAME, cancellation) +} + /// [`resolve`] against a caller-named process image, so a consumer's test /// binary - never named `promptforge-gateway` - can run the full liveness /// gauntlet. Test builds only, behind the `test-fixtures` feature. @@ -99,6 +113,86 @@ pub(crate) fn resolve_named(run_dir: &Path, image_name: &str) -> Result Result { + resolve_named_cancellable_with( + run_dir, + image_name, + cancellation, + ValidatedConnection::validate_named_cancellable, + ) +} + +fn resolve_named_cancellable_with( + run_dir: &Path, + image_name: &str, + cancellation: &CancellationToken, + validate: impl FnOnce( + ConnectionFile, + &str, + &CancellationToken, + ) -> Result, +) -> Result { + resolve_named_cancellable_with_effects( + run_dir, + image_name, + cancellation, + validate, + || {}, + remove_stale, + ) +} + +fn resolve_named_cancellable_with_effects( + run_dir: &Path, + image_name: &str, + cancellation: &CancellationToken, + validate: impl FnOnce( + ConnectionFile, + &str, + &CancellationToken, + ) -> Result, + mut before_remove: impl FnMut(), + mut remove: impl FnMut(&Path) -> Result<(), SidecarError>, +) -> Result { + if cancellation.is_cancelled() { + return Err(SidecarError::Cancelled); + } + let file = match ConnectionFile::read(run_dir) { + Ok(Some(file)) => file, + Ok(None) if cancellation.is_cancelled() => return Err(SidecarError::Cancelled), + Ok(None) => return Ok(Resolution::Absent), + Err(SidecarError::Parse { .. } | SidecarError::Invalid { .. }) => { + before_remove(); + remove_stale_if_active(run_dir, cancellation, &mut remove)?; + return Ok(Resolution::Stale(StaleReason::Invalid)); + } + Err(error) => return Err(error), + }; + match validate(file, image_name, cancellation) { + Ok(validated) => Ok(Resolution::Attach(validated.into_connection_file())), + Err(ValidationError::Cancelled) => Err(SidecarError::Cancelled), + Err(ValidationError::Stale(reason)) => { + before_remove(); + remove_stale_if_active(run_dir, cancellation, &mut remove)?; + Ok(Resolution::Stale(reason)) + } + } +} + +fn remove_stale_if_active( + run_dir: &Path, + cancellation: &CancellationToken, + remove: &mut impl FnMut(&Path) -> Result<(), SidecarError>, +) -> Result<(), SidecarError> { + cancellation + .run_if_active(|| remove(run_dir)) + .unwrap_or(Err(SidecarError::Cancelled)) +} + /// 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 @@ -126,6 +220,18 @@ pub(crate) fn is_live(file: &ConnectionFile, image_name: &str) -> bool { ValidatedConnection::validate_named(file.clone(), image_name).is_ok() } +pub(crate) fn is_live_cancellable( + file: &ConnectionFile, + image_name: &str, + cancellation: &CancellationToken, +) -> Result { + match ValidatedConnection::validate_named_cancellable(file.clone(), image_name, cancellation) { + Ok(_) => Ok(true), + Err(ValidationError::Stale(_)) => Ok(false), + Err(ValidationError::Cancelled) => Err(SidecarError::Cancelled), + } +} + /// Deletes the stale connection file, tolerating a concurrent deletion. fn remove_stale(run_dir: &Path) -> Result<(), SidecarError> { let path = connection_file_path(run_dir); @@ -145,6 +251,7 @@ mod tests { use std::io::{Read, Write as _}; use std::net::TcpListener; + use std::sync::mpsc; use crate::paths::connection_file_path; @@ -337,6 +444,142 @@ mod tests { ); } + #[test] + fn cancellation_during_resolve_leaves_the_connection_file_untouched() { + let dir = tempfile::TempDir::new().expect("tempdir"); + live_file(1, "key").write_to(dir.path()).expect("write"); + let cancellation = crate::CancellationToken::new(); + let worker_cancellation = cancellation.clone(); + let run_dir = dir.path().to_owned(); + let image_name = own_image_name(); + let (entered, blocked) = mpsc::channel(); + let worker = std::thread::spawn(move || { + resolve_named_cancellable_with( + &run_dir, + &image_name, + &worker_cancellation, + |_, _, cancellation| { + entered.send(()).expect("announce blocked resolve"); + let _ = cancellation.wait_timeout(std::time::Duration::from_secs(30)); + Err(crate::ValidationError::Cancelled) + }, + ) + }); + blocked + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("the resolve phase blocks deterministically"); + + let started = std::time::Instant::now(); + cancellation.cancel(); + let result = worker.join().expect("the resolve worker joins"); + + assert!( + started.elapsed() < std::time::Duration::from_millis(250), + "cancellation bounds the blocked resolve" + ); + assert!(matches!(result, Err(SidecarError::Cancelled))); + assert!( + connection_file_path(dir.path()).exists(), + "cancellation never classifies or deletes the connection file" + ); + } + + #[test] + fn cancellation_immediately_before_invalid_file_removal_preserves_the_file() { + let dir = tempfile::TempDir::new().expect("tempdir"); + fs::write(connection_file_path(dir.path()), b"not json").expect("write invalid fixture"); + let cancellation = crate::CancellationToken::new(); + let worker_cancellation = cancellation.clone(); + let run_dir = dir.path().to_owned(); + let (entered, blocked) = mpsc::channel(); + let (release, released) = mpsc::channel(); + let removals = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let worker_removals = std::sync::Arc::clone(&removals); + let worker = std::thread::spawn(move || { + resolve_named_cancellable_with_effects( + &run_dir, + "unused", + &worker_cancellation, + |_, _, _| -> Result { + panic!("an invalid file never reaches validation") + }, + || { + entered.send(()).expect("announce invalid-file removal"); + released.recv().expect("release invalid-file removal"); + }, + |run_dir| { + worker_removals.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + remove_stale(run_dir) + }, + ) + }); + blocked + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("resolution pauses immediately before invalid-file removal"); + + cancellation.cancel(); + release.send(()).expect("release invalid-file removal"); + let result = worker.join().expect("resolve worker joins"); + + assert!(matches!(result, Err(SidecarError::Cancelled))); + assert_eq!( + removals.load(std::sync::atomic::Ordering::SeqCst), + 0, + "invalid-file cleanup cannot begin after cancellation returns" + ); + assert!( + connection_file_path(dir.path()).exists(), + "the cancelled invalid file remains untouched" + ); + } + + #[test] + fn cancellation_immediately_before_failed_validation_removal_preserves_the_file() { + let dir = tempfile::TempDir::new().expect("tempdir"); + live_file(1, "key").write_to(dir.path()).expect("write"); + let cancellation = crate::CancellationToken::new(); + let worker_cancellation = cancellation.clone(); + let run_dir = dir.path().to_owned(); + let (entered, blocked) = mpsc::channel(); + let (release, released) = mpsc::channel(); + let removals = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let worker_removals = std::sync::Arc::clone(&removals); + let worker = std::thread::spawn(move || { + resolve_named_cancellable_with_effects( + &run_dir, + "unused", + &worker_cancellation, + |_, _, _| Err(ValidationError::Stale(StaleReason::HealthFailed)), + || { + entered.send(()).expect("announce stale-file removal"); + released.recv().expect("release stale-file removal"); + }, + |run_dir| { + worker_removals.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + remove_stale(run_dir) + }, + ) + }); + blocked + .recv_timeout(std::time::Duration::from_secs(1)) + .expect("resolution pauses immediately before stale-file removal"); + + cancellation.cancel(); + release.send(()).expect("release stale-file removal"); + let result = worker.join().expect("resolve worker joins"); + + assert!(matches!(result, Err(SidecarError::Cancelled))); + assert_eq!( + removals.load(std::sync::atomic::Ordering::SeqCst), + 0, + "failed-validation cleanup cannot begin after cancellation returns" + ); + assert!( + connection_file_path(dir.path()).exists(), + "the cancelled stale file remains untouched" + ); + } + #[test] fn is_running_reports_a_live_gateway_without_touching_the_file() { let dir = tempfile::TempDir::new().expect("tempdir"); diff --git a/crates/shared-sidecar/src/validated.rs b/crates/shared-sidecar/src/validated.rs index 5d96b1cf..cbf544ca 100644 --- a/crates/shared-sidecar/src/validated.rs +++ b/crates/shared-sidecar/src/validated.rs @@ -6,10 +6,10 @@ 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}; +use crate::{CancellationToken, ConnectionFile}; /// The image file name a live Gateway process must have. #[cfg(windows)] @@ -24,6 +24,18 @@ 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 cancellable validation failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[non_exhaustive] +pub enum ValidationError { + /// The caller cancelled validation. + #[error("connection validation was cancelled")] + Cancelled, + /// The connection failed one of the liveness or authority checks. + #[error(transparent)] + Stale(#[from] StaleReason), +} + /// A Gateway connection proven live and authorized at construction time. /// /// Safe code outside this crate cannot construct the capability directly. @@ -95,6 +107,18 @@ impl ValidatedConnection { Self::validate_named(connection, GATEWAY_IMAGE_NAME) } + /// Validates a raw connection while observing caller cancellation. + /// + /// # Errors + /// Returns [`ValidationError::Cancelled`] when cancellation wins, or + /// [`ValidationError::Stale`] when a liveness or authority check fails. + pub fn validate_cancellable( + connection: ConnectionFile, + cancellation: &CancellationToken, + ) -> Result { + Self::validate_named_cancellable(connection, GATEWAY_IMAGE_NAME, cancellation) + } + pub(crate) fn validate_named( connection: ConnectionFile, image_name: &str, @@ -109,6 +133,28 @@ impl ValidatedConnection { ) } + pub(crate) fn validate_named_cancellable( + connection: ConnectionFile, + image_name: &str, + cancellation: &CancellationToken, + ) -> Result { + validate_cancellable_with( + connection, + image_name, + cancellation, + process_identity, + |address, bearer, budget, cancellation| { + health::probe_connection_cancellable( + address, + KEY_PROBE_PATH, + bearer, + budget, + cancellation, + ) + }, + ) + } + /// The validated Gateway's loopback port. #[must_use] pub const fn port(&self) -> u16 { @@ -204,7 +250,9 @@ fn validate_with( } 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::Cancelled | ConnectionProbe::HealthFailed => { + return Err(StaleReason::HealthFailed); + } ConnectionProbe::KeyRejected => return Err(StaleReason::KeyRejected), ConnectionProbe::Accepted => {} } @@ -220,6 +268,65 @@ fn validate_with( }) } +fn validate_cancellable_with( + connection: ConnectionFile, + image_name: &str, + cancellation: &CancellationToken, + mut observe_process: impl FnMut(u32) -> Option, + prove_connection: impl FnOnce(&str, &str, Duration, &CancellationToken) -> ConnectionProbe, +) -> Result { + if cancellation.is_cancelled() { + return Err(ValidationError::Cancelled); + } + if connection.validation_error().is_some() { + return Err(StaleReason::Invalid.into()); + } + let Some(before_observation) = cancellation.run_if_active(|| observe_process(connection.pid)) + else { + return Err(ValidationError::Cancelled); + }; + let Some(before) = before_observation else { + return Err(StaleReason::ProcessDead.into()); + }; + if cancellation.is_cancelled() { + return Err(ValidationError::Cancelled); + } + if !image_name_matches(&before.image, image_name) { + return Err(StaleReason::ImageMismatch.into()); + } + if !connection.has_boot_identity() { + return Err(StaleReason::BootIdentityInvalid.into()); + } + let address = format!("127.0.0.1:{}", connection.port); + let proof = prove_connection(&address, &connection.api_key, LIVENESS_BUDGET, cancellation); + if cancellation.is_cancelled() || proof == ConnectionProbe::Cancelled { + return Err(ValidationError::Cancelled); + } + match proof { + ConnectionProbe::HealthFailed => return Err(StaleReason::HealthFailed.into()), + ConnectionProbe::KeyRejected => return Err(StaleReason::KeyRejected.into()), + ConnectionProbe::Accepted => {} + ConnectionProbe::Cancelled => return Err(ValidationError::Cancelled), + } + let Some(after_observation) = cancellation.run_if_active(|| observe_process(connection.pid)) + else { + return Err(ValidationError::Cancelled); + }; + let Some(after) = after_observation else { + return Err(StaleReason::ProcessChanged.into()); + }; + if cancellation.is_cancelled() { + return Err(ValidationError::Cancelled); + } + if before != after { + return Err(StaleReason::ProcessChanged.into()); + } + Ok(ValidatedConnection { + connection, + process_identity: before, + }) +} + fn image_name_matches(image: &Path, expected: &str) -> bool { let Some(name) = image.file_name() else { return false; @@ -243,6 +350,8 @@ mod tests { use std::io::{Read, Write as _}; use std::net::TcpListener; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, mpsc}; use crate::ConnectionFile; @@ -440,6 +549,95 @@ mod tests { ); } + #[test] + fn cancellation_inside_the_first_identity_probe_prevents_observation() { + let cancellation = crate::CancellationToken::new(); + let worker_cancellation = cancellation.clone(); + let image_name = own_image_name(); + let identity = ProcessIdentity::for_test(std::path::PathBuf::from(&image_name), 41); + let observations = Arc::new(AtomicUsize::new(0)); + let worker_observations = Arc::clone(&observations); + let (entered, blocked) = mpsc::channel(); + let worker = std::thread::spawn(move || { + validate_cancellable_with( + connection(8081, "key"), + &image_name, + &worker_cancellation, + |_| { + entered.send(()).expect("announce identity probe"); + if !worker_cancellation.wait_timeout(Duration::from_secs(30)) { + worker_observations.fetch_add(1, Ordering::SeqCst); + } + Some(identity.clone()) + }, + |_, _, _, _| ConnectionProbe::Accepted, + ) + }); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("the first process identity probe blocks deterministically"); + + let started = std::time::Instant::now(); + cancellation.cancel(); + let result = worker.join().expect("the validation worker joins"); + + assert!( + started.elapsed() < Duration::from_millis(250), + "cancellation wakes the in-progress process identity probe" + ); + assert!(matches!(result, Err(ValidationError::Cancelled))); + assert_eq!( + observations.load(Ordering::SeqCst), + 0, + "no process observation occurs after cancellation" + ); + } + + #[test] + fn cancellation_during_validation_prevents_the_second_identity_observation() { + let cancellation = crate::CancellationToken::new(); + let worker_cancellation = cancellation.clone(); + let image_name = own_image_name(); + let identity = ProcessIdentity::for_test(std::path::PathBuf::from(&image_name), 41); + let observations = Arc::new(AtomicUsize::new(0)); + let worker_observations = Arc::clone(&observations); + let (entered, blocked) = mpsc::channel(); + let worker = std::thread::spawn(move || { + validate_cancellable_with( + connection(8081, "key"), + &image_name, + &worker_cancellation, + |_| { + worker_observations.fetch_add(1, Ordering::SeqCst); + Some(identity.clone()) + }, + |_, _, _, cancellation| { + entered.send(()).expect("announce blocked validation"); + let _ = cancellation.wait_timeout(Duration::from_secs(30)); + ConnectionProbe::Accepted + }, + ) + }); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("the validation phase blocks deterministically"); + + let started = std::time::Instant::now(); + cancellation.cancel(); + let result = worker.join().expect("the validation worker joins"); + + assert!( + started.elapsed() < Duration::from_millis(250), + "cancellation bounds the blocked validation" + ); + assert!(matches!(result, Err(ValidationError::Cancelled))); + assert_eq!( + observations.load(Ordering::SeqCst), + 1, + "validation performs no post-cancel process probe" + ); + } + #[test] fn forged_file_identity_cannot_alias_a_reused_process() { let raw = connection(8081, "key"); diff --git a/crates/workshop-server/README.md b/crates/workshop-server/README.md index 0296f0fd..8c8c9e1a 100644 --- a/crates/workshop-server/README.md +++ b/crates/workshop-server/README.md @@ -62,7 +62,7 @@ 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. +An embedding host can publish a local Gateway replacement only by presenting `shared_sidecar::ValidatedConnection`; raw connection files are not accepted. The cancellable publication entry point also stops lock contention without changing the current generation when its host is shutting down. 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 diff --git a/crates/workshop-server/module-ceilings.toml b/crates/workshop-server/module-ceilings.toml index 51e70e77..efc59825 100644 --- a/crates/workshop-server/module-ceilings.toml +++ b/crates/workshop-server/module-ceilings.toml @@ -73,6 +73,8 @@ # New module: one atomically replaceable Gateway URL and bearer generation # shared by HTTP, Realtime, progress, heartbeat, and agent model clients. "gateway_binding.rs" = 267 +# Cancellable replacement lock acquisition and validated snapshot publication. +"gateway_binding/publication.rs" = 85 # Current validated-snapshot shutdown authority for the embedding host. "gateway_binding/shutdown.rs" = 24 # Capability-only publication and coherent immutable snapshot coverage. @@ -80,6 +82,8 @@ # Deterministically synchronized publisher and reader coverage for complete # immutable Gateway generations. "gateway_binding/tests/atomic.rs" = 80 +# Real publication-lock contention and cancellation coverage. +"gateway_binding/tests/publication.rs" = 59 # Replacement-versus-quit coherence and configured-LAN denial coverage. "gateway_binding/tests/shutdown.rs" = 77 # Split from gateway.rs: fixed-target authenticated WebSocket connections diff --git a/crates/workshop-server/src/gateway_binding.rs b/crates/workshop-server/src/gateway_binding.rs index b0dc3957..9388b249 100644 --- a/crates/workshop-server/src/gateway_binding.rs +++ b/crates/workshop-server/src/gateway_binding.rs @@ -6,6 +6,7 @@ //! atomic store, then notifies long-lived tasks to reconnect. Explicitly //! configured endpoints never receive an updater from the desktop shell. +mod publication; mod shutdown; use std::fmt; @@ -166,15 +167,20 @@ impl GatewayBinding { api_key: &str, identity: Option, ) -> Result<(), GatewayError> { + let snapshot = build_snapshot(base_url, api_key, 0, identity)?; let _replacement = self .replacement .lock() .unwrap_or_else(PoisonError::into_inner); + self.publish_snapshot(snapshot); + Ok(()) + } + + fn publish_snapshot(&self, mut snapshot: GatewaySnapshot) { 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); + snapshot.generation = generation; + self.current.store(Arc::new(snapshot)); self.changed.send_replace(generation); - Ok(()) } /// Creates the restricted handle the desktop host uses for sidecar updates. diff --git a/crates/workshop-server/src/gateway_binding/publication.rs b/crates/workshop-server/src/gateway_binding/publication.rs new file mode 100644 index 00000000..baaa92ec --- /dev/null +++ b/crates/workshop-server/src/gateway_binding/publication.rs @@ -0,0 +1,85 @@ +//! Cancellation-aware publication of validated Gateway replacements. + +use std::sync::TryLockError; +use std::time::Duration; + +use super::{GatewayBinding, GatewayUpdater, build_snapshot}; +use crate::gateway::GatewayError; + +/// Cancellable replacement lock retry cadence. +const REPLACEMENT_RETRY_INTERVAL: Duration = Duration::from_millis(10); + +impl GatewayBinding { + fn replace_with_identity_cancellable( + &self, + base_url: &str, + api_key: &str, + identity: shared_sidecar::ValidatedConnection, + cancellation: &shared_sidecar::CancellationToken, + ) -> Result { + self.replace_with_identity_cancellable_with_wait( + base_url, + api_key, + identity, + cancellation, + shared_sidecar::CancellationToken::wait_timeout, + ) + } + + pub(super) fn replace_with_identity_cancellable_with_wait( + &self, + base_url: &str, + api_key: &str, + identity: shared_sidecar::ValidatedConnection, + cancellation: &shared_sidecar::CancellationToken, + mut wait: impl FnMut(&shared_sidecar::CancellationToken, Duration) -> bool, + ) -> Result { + if cancellation.is_cancelled() { + return Ok(false); + } + let snapshot = build_snapshot(base_url, api_key, 0, Some(identity))?; + cancellation + .run_if_active(|| { + let _replacement = loop { + match self.replacement.try_lock() { + Ok(replacement) => break replacement, + Err(TryLockError::Poisoned(error)) => break error.into_inner(), + Err(TryLockError::WouldBlock) => { + if wait(cancellation, REPLACEMENT_RETRY_INTERVAL) { + return Ok(false); + } + } + } + }; + if cancellation.is_cancelled() { + return Ok(false); + } + self.publish_snapshot(snapshot); + Ok(true) + }) + .unwrap_or(Ok(false)) + } +} + +impl GatewayUpdater { + /// Atomically replaces the local Gateway unless caller cancellation wins. + /// + /// Returns `Ok(false)` without publication when cancellation wins while + /// another publisher owns the replacement lock. + /// + /// # Errors + /// Returns [`GatewayError::Build`] if the replacement clients cannot + /// initialize. + pub fn replace_sidecar_cancellable( + &self, + connection: &shared_sidecar::ValidatedConnection, + cancellation: &shared_sidecar::CancellationToken, + ) -> Result { + self.binding.replace_with_identity_cancellable( + &format!("http://127.0.0.1:{}", connection.port()), + connection.api_key(), + connection.clone(), + cancellation, + ) + } +} diff --git a/crates/workshop-server/src/gateway_binding/tests.rs b/crates/workshop-server/src/gateway_binding/tests.rs index 82b68b6e..3ff4a2b5 100644 --- a/crates/workshop-server/src/gateway_binding/tests.rs +++ b/crates/workshop-server/src/gateway_binding/tests.rs @@ -1,6 +1,7 @@ use super::*; mod atomic; +mod publication; mod shutdown; fn validated_connection( diff --git a/crates/workshop-server/src/gateway_binding/tests/publication.rs b/crates/workshop-server/src/gateway_binding/tests/publication.rs new file mode 100644 index 00000000..d6cbc12b --- /dev/null +++ b/crates/workshop-server/src/gateway_binding/tests/publication.rs @@ -0,0 +1,59 @@ +use super::*; + +use std::sync::PoisonError; +use std::time::{Duration, Instant}; + +#[test] +fn cancellation_wakes_a_replacement_contending_on_the_publication_lock() { + let binding = GatewayBinding::new("http://127.0.0.1:54375", "old-key").expect("binding builds"); + let original = binding.snapshot(); + let gateway = crate::test_gateway::ValidatedGateway::spawn("new-key"); + let validated = + validated_connection(&gateway, "new-key", 1_778_000_001, "2026-09-07T18:00:01Z"); + let held = binding + .replacement + .lock() + .unwrap_or_else(PoisonError::into_inner); + let worker_binding = binding.clone(); + let cancellation = shared_sidecar::CancellationToken::new(); + let worker_cancellation = cancellation.clone(); + let (entered, blocked) = std::sync::mpsc::channel(); + let worker = std::thread::spawn(move || { + let base_url = format!("http://127.0.0.1:{}", validated.port()); + let api_key = validated.api_key().to_owned(); + worker_binding.replace_with_identity_cancellable_with_wait( + &base_url, + &api_key, + validated, + &worker_cancellation, + |cancellation, delay| { + entered + .send(()) + .expect("announce publication lock contention"); + cancellation.wait_timeout(delay) + }, + ) + }); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("replacement blocks inside the real publication boundary"); + + let started = Instant::now(); + cancellation.cancel(); + let published = worker + .join() + .expect("replacement worker joins") + .expect("replacement build succeeds"); + drop(held); + + assert!( + started.elapsed() < Duration::from_millis(250), + "cancellation wakes publication lock contention" + ); + assert!(!published, "the cancelled replacement is not published"); + assert_eq!( + binding.snapshot().generation(), + original.generation(), + "the authoritative snapshot remains the pre-cancel generation" + ); +} diff --git a/crates/workshop/src/gateway.rs b/crates/workshop/src/gateway.rs index f8c04564..8dfe6161 100644 --- a/crates/workshop/src/gateway.rs +++ b/crates/workshop/src/gateway.rs @@ -19,12 +19,12 @@ //! server's current validated binding snapshot. 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, ValidatedConnection, + CancellationToken, ConnectionFile, LaunchDecision, Resolution, SidecarError, + ValidatedConnection, }; use workshop_server::Config; @@ -52,6 +52,9 @@ const SUPERVISION_BASE_DELAY: Duration = Duration::from_millis(250); /// Ceiling on repeated sidecar recovery attempts. const SUPERVISION_MAX_DELAY: Duration = Duration::from_secs(30); +/// Maximum designed supervisor shutdown latency. +const SUPERVISOR_SHUTDOWN_BUDGET: Duration = Duration::from_secs(3); + /// One sidecar liveness observation. enum SupervisionProbe { /// Another process already published a live replacement. @@ -63,20 +66,49 @@ enum SupervisionProbe { /// The running local-sidecar supervisor. #[derive(Debug)] pub(crate) struct GatewaySupervisor { - stop: Option>, + cancellation: CancellationToken, thread: Option>, } impl GatewaySupervisor { - /// Stops supervision without waiting for a probe or backoff interval. + fn spawn(supervise: impl FnOnce(CancellationToken) + Send + 'static) -> anyhow::Result { + let cancellation = CancellationToken::new(); + let worker_cancellation = cancellation.clone(); + let thread = std::thread::Builder::new() + .name("gateway-supervisor".to_owned()) + .spawn(move || supervise(worker_cancellation)) + .context("spawn the gateway supervisor")?; + Ok(Self { + cancellation, + thread: Some(thread), + }) + } + + /// Cancels supervision and joins its thread. pub(crate) fn shutdown(mut self) { - if let Some(stop) = self.stop.take() { - let _ = stop.send(()); + self.cancel_and_join(); + } + + fn cancel_and_join(&mut self) { + let started = Instant::now(); + self.cancellation.cancel(); + if let Some(thread) = self.thread.take() + && thread.join().is_err() + { + eprintln!("the gateway supervisor panicked during shutdown"); } - // 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()); + let elapsed = started.elapsed(); + if elapsed > SUPERVISOR_SHUTDOWN_BUDGET { + eprintln!( + "the gateway supervisor exceeded its {SUPERVISOR_SHUTDOWN_BUDGET:?} shutdown budget: {elapsed:?}" + ); + } + } +} + +impl Drop for GatewaySupervisor { + fn drop(&mut self) { + self.cancel_and_join(); } } @@ -220,6 +252,67 @@ fn launch_and_attach(run_dir: &Path, exe: &Path) -> anyhow::Result anyhow::Result { + launch_and_attach_cancellable_with( + run_dir, + exe, + cancellation, + shared_sidecar::launch_or_attach_cancellable, + |exe, _| spawn_detached(exe), + wait_for_launched_file_cancellable, + ) +} + +fn launch_and_attach_cancellable_with( + run_dir: &Path, + exe: &Path, + cancellation: &CancellationToken, + settle: Settle, + spawn: Spawn, + wait: Wait, +) -> anyhow::Result +where + Settle: FnOnce(&Path, Duration, &CancellationToken) -> Result, + Spawn: FnOnce(&Path, &CancellationToken) -> std::io::Result<()>, + Wait: FnOnce(&Path, Duration, &CancellationToken) -> anyhow::Result, +{ + match settle(run_dir, LAUNCH_TIMEOUT, cancellation).context("settle the gateway launch race")? { + LaunchDecision::Attach(file) => { + if cancellation.is_cancelled() { + anyhow::bail!("gateway attachment was cancelled"); + } + Ok(file) + } + LaunchDecision::Launch(lock) => { + run_effect_if_active(cancellation, "gateway launch", |cancellation| { + if cancellation.is_cancelled() { + anyhow::bail!("gateway launch was cancelled"); + } + spawn(exe, cancellation).with_context(|| format!("spawn {}", exe.display())) + })?; + let file = wait(run_dir, LAUNCH_TIMEOUT, cancellation)?; + drop(lock); + Ok(file) + } + decision => anyhow::bail!("an unknown launch decision: {decision:?}"), + } +} + +fn run_effect_if_active( + cancellation: &CancellationToken, + phase: &'static str, + operation: impl FnOnce(&CancellationToken) -> anyhow::Result, +) -> anyhow::Result { + match cancellation.run_if_active(|| operation(cancellation)) { + Some(result) => result, + None => anyhow::bail!("{phase} was cancelled"), + } +} + /// Waits for the launched gateway's connection file to appear, become ready, /// and pass the shared process-image, health, and bearer validation. fn wait_for_launched_file(run_dir: &Path, timeout: Duration) -> anyhow::Result { @@ -256,6 +349,71 @@ where } } +fn wait_for_launched_file_cancellable( + run_dir: &Path, + timeout: Duration, + cancellation: &CancellationToken, +) -> anyhow::Result { + wait_for_launched_file_cancellable_with( + run_dir, + timeout, + cancellation, + shared_sidecar::wait_for_health_cancellable, + shared_sidecar::resolve_cancellable, + ) +} + +fn wait_for_launched_file_cancellable_with( + run_dir: &Path, + timeout: Duration, + cancellation: &CancellationToken, + mut health: Health, + mut resolve: Resolve, +) -> anyhow::Result +where + Health: FnMut(&str, Duration, &CancellationToken) -> Result<(), shared_sidecar::HealthError>, + Resolve: FnMut(&Path, &CancellationToken) -> Result, +{ + let deadline = Instant::now() + timeout; + loop { + if cancellation.is_cancelled() { + anyhow::bail!("the launched gateway wait was cancelled"); + } + if let Ok(Some(file)) = ConnectionFile::read(run_dir) { + let remaining = deadline.saturating_duration_since(Instant::now()); + let url = format!("http://127.0.0.1:{}", file.port); + health(&url, remaining, cancellation) + .context("the launched gateway did not answer its health probe")?; + if cancellation.is_cancelled() { + anyhow::bail!("the launched gateway wait was cancelled"); + } + match resolve(run_dir, cancellation) { + Ok(Resolution::Attach(validated)) => { + if cancellation.is_cancelled() { + anyhow::bail!("the launched gateway wait was cancelled"); + } + return Ok(validated); + } + Err(SidecarError::Cancelled) => { + anyhow::bail!("the launched gateway wait was cancelled"); + } + Ok(_) | Err(_) => {} + } + } + if cancellation.is_cancelled() { + anyhow::bail!("the launched gateway wait was cancelled"); + } + if Instant::now() >= deadline { + anyhow::bail!( + "the launched gateway wrote no validated connection file within {timeout:?}" + ); + } + if cancellation.wait_timeout(POLL_INTERVAL) { + anyhow::bail!("the launched gateway wait was cancelled"); + } + } +} + /// 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 @@ -301,6 +459,19 @@ fn spawn_detached(exe: &Path) -> std::io::Result<()> { Ok(()) } +fn validate_and_publish_with( + file: &ConnectionFile, + cancellation: &CancellationToken, + validate: impl FnOnce(&ConnectionFile, &CancellationToken) -> anyhow::Result, + publish: impl FnOnce(&T, &CancellationToken) -> anyhow::Result<()>, +) -> anyhow::Result<()> { + let validated = validate(file, cancellation)?; + if cancellation.is_cancelled() { + anyhow::bail!("gateway publication was cancelled"); + } + publish(&validated, cancellation) +} + /// Starts runtime supervision only for a connection-file sidecar. /// /// Explicitly configured LAN endpoints return `None`: their address is fixed, @@ -319,45 +490,48 @@ pub(crate) fn supervise( .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")?; - 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), - })) + GatewaySupervisor::spawn(move |cancellation| { + run_supervision( + initial, + |_, cancellation| match shared_sidecar::resolve_cancellable(&run_dir, cancellation) { + Ok(Resolution::Attach(file)) => SupervisionProbe::Replacement(file), + Ok(_) | Err(SidecarError::Cancelled) => SupervisionProbe::Missing, + Err(error) => { + eprintln!("could not re-resolve the local gateway: {error}"); + SupervisionProbe::Missing + } + }, + |cancellation| { + let exe = sibling.as_deref().context( + "the local gateway disappeared and no sibling gateway executable is installed", + )?; + launch_and_attach_cancellable(&run_dir, exe, cancellation) + }, + |file, cancellation| { + validate_and_publish_with( + file, + cancellation, + |file, cancellation| { + ValidatedConnection::validate_cancellable(file.clone(), cancellation) + .context("validate the replacement gateway identity") + }, + |validated, cancellation| { + if updater + .replace_sidecar_cancellable(validated, cancellation) + .context("publish the replacement gateway endpoint")? + { + Ok(()) + } else { + anyhow::bail!("gateway publication was cancelled") + } + }, + ) + }, + |delay, cancellation| cancellation.wait_timeout(delay), + &cancellation, + ); + }) + .map(Some) } /// Runs the supervision state machine with I/O injected for deterministic @@ -368,25 +542,36 @@ fn run_supervision( mut recover: Recover, mut publish: Publish, mut wait: Wait, + cancellation: &CancellationToken, ) where - Probe: FnMut(&ConnectionFile) -> SupervisionProbe, - Recover: FnMut() -> Result, - Publish: FnMut(&ConnectionFile) -> Result<(), Error>, - Wait: FnMut(Duration) -> bool, + Probe: FnMut(&ConnectionFile, &CancellationToken) -> SupervisionProbe, + Recover: FnMut(&CancellationToken) -> Result, + Publish: FnMut(&ConnectionFile, &CancellationToken) -> Result<(), Error>, + Wait: FnMut(Duration, &CancellationToken) -> bool, Error: std::fmt::Display, { let mut retry_delay = SUPERVISION_BASE_DELAY; loop { - match probe(¤t) { + if cancellation.is_cancelled() { + return; + } + let observation = probe(¤t, cancellation); + if cancellation.is_cancelled() { + return; + } + match observation { SupervisionProbe::Replacement(file) if same_gateway_identity(&file, ¤t) => { retry_delay = SUPERVISION_BASE_DELAY; - if wait(SUPERVISION_INTERVAL) { + if wait(SUPERVISION_INTERVAL, cancellation) { return; } continue; } - SupervisionProbe::Replacement(file) => match publish(&file) { + SupervisionProbe::Replacement(file) => match publish(&file, cancellation) { Ok(()) => { + if cancellation.is_cancelled() { + return; + } current = file; retry_delay = SUPERVISION_BASE_DELAY; continue; @@ -395,9 +580,13 @@ fn run_supervision( eprintln!("could not publish a replacement local gateway: {error}"); } }, - SupervisionProbe::Missing => match recover() { - Ok(file) => match publish(&file) { + SupervisionProbe::Missing => match recover(cancellation) { + Ok(_) if cancellation.is_cancelled() => return, + Ok(file) => match publish(&file, cancellation) { Ok(()) => { + if cancellation.is_cancelled() { + return; + } current = file; retry_delay = SUPERVISION_BASE_DELAY; continue; @@ -411,7 +600,7 @@ fn run_supervision( } }, } - if wait(retry_delay) { + if cancellation.is_cancelled() || wait(retry_delay, cancellation) { return; } retry_delay = retry_delay.saturating_mul(2).min(SUPERVISION_MAX_DELAY); @@ -430,7 +619,8 @@ mod tests { use std::io::{Read, Write as _}; use std::net::{TcpListener, TcpStream}; use std::process::{Child, Command, Stdio}; - use std::sync::Arc; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::{Arc, mpsc}; /// The test process's own image name, so the probe's pid and image /// checks pass and the test reaches the liveness probes. @@ -892,29 +1082,30 @@ mod tests { let elapsed = Cell::new(Duration::ZERO); let recoveries = Cell::new(0_u8); let published = RefCell::new(Vec::new()); + let cancellation = CancellationToken::new(); run_supervision( original.clone(), - |current| { + |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| { + |file, _| { publish_replacement(file)?; published.borrow_mut().push(file.clone()); Ok::<(), anyhow::Error>(()) }, - |delay| { + |delay, _| { assert!( delay <= SUPERVISION_MAX_DELAY, "every supervision wait is capped: {delay:?}" @@ -922,6 +1113,7 @@ mod tests { elapsed.set(elapsed.get() + delay); !published.borrow().is_empty() }, + &cancellation, ); assert!( @@ -964,24 +1156,26 @@ mod tests { ..original.clone() }; let published = RefCell::new(Vec::new()); + let cancellation = CancellationToken::new(); run_supervision( original.clone(), - |current| { + |current, _| { if published.borrow().is_empty() { SupervisionProbe::Replacement(replacement.clone()) } else { SupervisionProbe::Replacement(current.clone()) } }, - || -> anyhow::Result { + |_| -> anyhow::Result { panic!("a validated replacement does not need a relaunch") }, - |file| { + |file, _| { published.borrow_mut().push(file.clone()); Ok(()) }, - |_| !published.borrow().is_empty(), + |_, _| !published.borrow().is_empty(), + &cancellation, ); assert_eq!( @@ -1015,4 +1209,301 @@ mod tests { assert!(!same_gateway_identity(&original, &new_boot)); assert!(same_gateway_identity(&original, &endpoint_only)); } + + fn assert_bounded_supervisor_shutdown(supervisor: GatewaySupervisor, finished: &AtomicBool) { + let started = Instant::now(); + supervisor.shutdown(); + assert!( + started.elapsed() < Duration::from_millis(250), + "Workshop exit joins the cancelled supervisor within its budget" + ); + assert!( + finished.load(Ordering::SeqCst), + "shutdown returns only after the supervisor thread exits" + ); + } + + #[test] + fn exit_joins_a_supervisor_blocked_in_resolve_before_recovery() { + let (entered, blocked) = mpsc::channel(); + let recoveries = Arc::new(AtomicUsize::new(0)); + let worker_recoveries = Arc::clone(&recoveries); + let finished = Arc::new(AtomicBool::new(false)); + let worker_finished = Arc::clone(&finished); + let supervisor = GatewaySupervisor::spawn(move |cancellation| { + run_supervision( + live_file(54_375, "stable-key"), + |_, cancellation| { + entered.send(()).expect("announce blocked resolve"); + let _ = cancellation.wait_timeout(Duration::from_secs(30)); + SupervisionProbe::Missing + }, + |_| { + worker_recoveries.fetch_add(1, Ordering::SeqCst); + anyhow::bail!("recovery must not start after cancellation") + }, + |_, _| Ok::<(), anyhow::Error>(()), + |delay, cancellation| cancellation.wait_timeout(delay), + &cancellation, + ); + worker_finished.store(true, Ordering::SeqCst); + }) + .expect("spawn test supervisor"); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("the resolve phase blocks deterministically"); + + assert_bounded_supervisor_shutdown(supervisor, &finished); + assert_eq!( + recoveries.load(Ordering::SeqCst), + 0, + "cancellation prevents every later recovery launch" + ); + } + + #[test] + fn exit_wakes_the_supervision_wait_without_a_later_probe() { + let (entered, blocked) = mpsc::channel(); + let probes = Arc::new(AtomicUsize::new(0)); + let worker_probes = Arc::clone(&probes); + let finished = Arc::new(AtomicBool::new(false)); + let worker_finished = Arc::clone(&finished); + let supervisor = GatewaySupervisor::spawn(move |cancellation| { + run_supervision( + live_file(54_375, "stable-key"), + |current, _| { + worker_probes.fetch_add(1, Ordering::SeqCst); + SupervisionProbe::Replacement(current.clone()) + }, + |_| -> anyhow::Result { + panic!("a healthy Gateway does not recover") + }, + |_, _| Ok::<(), anyhow::Error>(()), + |delay, cancellation| { + entered.send(()).expect("announce supervision wait"); + cancellation.wait_timeout(delay) + }, + &cancellation, + ); + worker_finished.store(true, Ordering::SeqCst); + }) + .expect("spawn test supervisor"); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("the supervision wait blocks deterministically"); + + assert_bounded_supervisor_shutdown(supervisor, &finished); + assert_eq!( + probes.load(Ordering::SeqCst), + 1, + "cancellation prevents every later liveness probe" + ); + } + + #[test] + fn exit_joins_a_supervisor_blocked_in_validation_before_publication() { + let (entered, blocked) = mpsc::channel(); + let publications = Arc::new(AtomicUsize::new(0)); + let worker_publications = Arc::clone(&publications); + let finished = Arc::new(AtomicBool::new(false)); + let worker_finished = Arc::clone(&finished); + let supervisor = GatewaySupervisor::spawn(move |cancellation| { + let result = validate_and_publish_with( + &live_file(54_375, "stable-key"), + &cancellation, + |_, cancellation| -> anyhow::Result<()> { + entered.send(()).expect("announce blocked validation"); + let _ = cancellation.wait_timeout(Duration::from_secs(30)); + anyhow::bail!("validation cancelled") + }, + |(), _| { + worker_publications.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + ); + assert!(result.is_err(), "the cancelled validation is rejected"); + worker_finished.store(true, Ordering::SeqCst); + }) + .expect("spawn test supervisor"); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("the validation phase blocks deterministically"); + + assert_bounded_supervisor_shutdown(supervisor, &finished); + assert_eq!( + publications.load(Ordering::SeqCst), + 0, + "cancelled validation cannot publish into the snapshot" + ); + } + + #[test] + fn exit_joins_a_supervisor_blocked_in_health_wait_before_resolve() { + let run = tempfile::TempDir::new().expect("tempdir"); + live_file(54_375, "stable-key") + .write_to(run.path()) + .expect("write candidate"); + let run_dir = run.path().to_owned(); + let (entered, blocked) = mpsc::channel(); + let resolves = Arc::new(AtomicUsize::new(0)); + let worker_resolves = Arc::clone(&resolves); + let finished = Arc::new(AtomicBool::new(false)); + let worker_finished = Arc::clone(&finished); + let supervisor = GatewaySupervisor::spawn(move |cancellation| { + let result = wait_for_launched_file_cancellable_with( + &run_dir, + Duration::from_secs(30), + &cancellation, + |_, _, cancellation| { + entered.send(()).expect("announce blocked health wait"); + let _ = cancellation.wait_timeout(Duration::from_secs(30)); + Err(shared_sidecar::HealthError::Cancelled) + }, + |_, _| { + worker_resolves.fetch_add(1, Ordering::SeqCst); + Ok(Resolution::Absent) + }, + ); + assert!(result.is_err(), "the cancelled health wait is rejected"); + worker_finished.store(true, Ordering::SeqCst); + }) + .expect("spawn test supervisor"); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("the health-wait phase blocks deterministically"); + + assert_bounded_supervisor_shutdown(supervisor, &finished); + assert_eq!( + resolves.load(Ordering::SeqCst), + 0, + "cancelled health waiting cannot start a later resolve" + ); + } + + #[test] + fn exit_joins_a_supervisor_blocked_in_launch_race_before_spawn() { + let run = tempfile::TempDir::new().expect("tempdir"); + let decision = shared_sidecar::launch_or_attach(run.path(), Duration::from_secs(1)) + .expect("acquire a launch decision"); + let run_dir = run.path().to_owned(); + let (entered, blocked) = mpsc::channel(); + let launches = Arc::new(AtomicUsize::new(0)); + let worker_launches = Arc::clone(&launches); + let finished = Arc::new(AtomicBool::new(false)); + let worker_finished = Arc::clone(&finished); + let supervisor = GatewaySupervisor::spawn(move |cancellation| { + let mut decision = Some(decision); + let result = launch_and_attach_cancellable_with( + &run_dir, + Path::new("unused-gateway"), + &cancellation, + |_, _, cancellation| { + entered.send(()).expect("announce blocked launch race"); + let _ = cancellation.wait_timeout(Duration::from_secs(30)); + Ok(decision.take().expect("one launch decision")) + }, + |_, _| { + worker_launches.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + |_, _, _| anyhow::bail!("the cancelled launch cannot wait for health"), + ); + assert!(result.is_err(), "the cancelled launch race is rejected"); + worker_finished.store(true, Ordering::SeqCst); + }) + .expect("spawn test supervisor"); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("the launch race blocks deterministically"); + + assert_bounded_supervisor_shutdown(supervisor, &finished); + assert_eq!( + launches.load(Ordering::SeqCst), + 0, + "cancelled launch racing cannot create a process" + ); + } + + #[test] + fn exit_joins_a_supervisor_blocked_inside_launch_without_process_creation() { + let run = tempfile::TempDir::new().expect("tempdir"); + let decision = shared_sidecar::launch_or_attach(run.path(), Duration::from_secs(1)) + .expect("acquire a launch decision"); + let run_dir = run.path().to_owned(); + let (entered, blocked) = mpsc::channel(); + let launches = Arc::new(AtomicUsize::new(0)); + let worker_launches = Arc::clone(&launches); + let finished = Arc::new(AtomicBool::new(false)); + let worker_finished = Arc::clone(&finished); + let supervisor = GatewaySupervisor::spawn(move |cancellation| { + let mut decision = Some(decision); + let result = launch_and_attach_cancellable_with( + &run_dir, + Path::new("unused-gateway"), + &cancellation, + |_, _, _| Ok(decision.take().expect("one launch decision")), + |_, cancellation| { + entered.send(()).expect("announce blocked launch"); + if cancellation.wait_timeout(Duration::from_secs(30)) { + return Err(std::io::Error::from(std::io::ErrorKind::Interrupted)); + } + worker_launches.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + |_, _, _| anyhow::bail!("the cancelled launch cannot wait for health"), + ); + assert!(result.is_err(), "the cancelled launch is rejected"); + worker_finished.store(true, Ordering::SeqCst); + }) + .expect("spawn test supervisor"); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("the process launch blocks deterministically inside its effect gate"); + + assert_bounded_supervisor_shutdown(supervisor, &finished); + assert_eq!( + launches.load(Ordering::SeqCst), + 0, + "the cancelled launch has no post-cancel effect" + ); + } + + #[test] + fn exit_joins_a_supervisor_blocked_inside_publication_without_replacement() { + let (entered, blocked) = mpsc::channel(); + let publications = Arc::new(AtomicUsize::new(0)); + let worker_publications = Arc::clone(&publications); + let finished = Arc::new(AtomicBool::new(false)); + let worker_finished = Arc::clone(&finished); + let supervisor = GatewaySupervisor::spawn(move |cancellation| { + let result = validate_and_publish_with( + &live_file(54_375, "stable-key"), + &cancellation, + |_, _| Ok(()), + |(), cancellation| { + run_effect_if_active(cancellation, "gateway publication", |cancellation| { + entered.send(()).expect("announce blocked publication"); + if cancellation.wait_timeout(Duration::from_secs(30)) { + anyhow::bail!("gateway publication was cancelled"); + } + worker_publications.fetch_add(1, Ordering::SeqCst); + Ok(()) + }) + }, + ); + assert!(result.is_err(), "the cancelled publication is rejected"); + worker_finished.store(true, Ordering::SeqCst); + }) + .expect("spawn test supervisor"); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("snapshot publication blocks deterministically inside its effect gate"); + + assert_bounded_supervisor_shutdown(supervisor, &finished); + assert_eq!( + publications.load(Ordering::SeqCst), + 0, + "cancellation prevents authoritative snapshot replacement" + ); + } } diff --git a/vibe/2026-09-07-1-promptforge-debt.md b/vibe/2026-09-07-1-promptforge-debt.md index fcf9d23f..ef2934cf 100644 --- a/vibe/2026-09-07-1-promptforge-debt.md +++ b/vibe/2026-09-07-1-promptforge-debt.md @@ -520,7 +520,7 @@ isProject: false - 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 +### Step 27: Join cancellation-aware sidecar shutdown [completed] - 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. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 2e6d9a36..31606d3d 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -205,10 +205,10 @@ N76 | observation | Violates A96 @ crates/workshop-server/ui/src/services/realti 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 +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; Join cancellation-aware sidecar shutdown +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; Join cancellation-aware sidecar shutdown +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; Join cancellation-aware sidecar shutdown +N83 | observation | oversized-unit @ crates/shared-sidecar/src/validated.rs: adds a 492-line validated connection module | Add validated sidecar connection capability; Join cancellation-aware sidecar shutdown 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 From 85da0024445dc2e912c42f503ace5f9736f1da30 Mon Sep 17 00:00:00 2001 From: Vinnie Falco Date: Mon, 7 Sep 2026 21:58:30 -0700 Subject: [PATCH 86/86] [WIP] Step 28: Split and ratchet sidecar lifecycle ownership --- Cargo.lock | 2 + crates/workshop-server/Cargo.toml | 3 +- crates/workshop-server/module-ceilings.toml | 7 +- crates/workshop-server/src/fixtures.rs | 2 + crates/workshop-server/src/lib.rs | 2 +- crates/workshop-server/src/test_gateway.rs | 134 +- .../src/test_gateway/process.rs | 122 ++ crates/workshop/Cargo.toml | 2 + crates/workshop/README.md | 2 +- crates/workshop/module-ceilings.toml | 30 + crates/workshop/src/gateway.rs | 1514 +---------------- crates/workshop/src/gateway/boot.rs | 193 +++ crates/workshop/src/gateway/identity.rs | 30 + crates/workshop/src/gateway/supervisor.rs | 372 ++++ crates/workshop/src/gateway/tests.rs | 149 ++ crates/workshop/src/gateway/tests/boot.rs | 191 +++ crates/workshop/src/gateway/tests/identity.rs | 37 + crates/workshop/src/gateway/tests/recovery.rs | 464 +++++ crates/workshop/src/main.rs | 2 +- crates/workshop/src/menu.rs | 11 +- crates/workshop/tests/module_ceiling.rs | 147 ++ guide/promptforge-workshop-guide.md | 11 +- guide/src/workshop/01-application.md | 11 +- vibe/archdoc-next.md | 3 + 24 files changed, 1846 insertions(+), 1595 deletions(-) create mode 100644 crates/workshop-server/src/test_gateway/process.rs create mode 100644 crates/workshop/module-ceilings.toml create mode 100644 crates/workshop/src/gateway/boot.rs create mode 100644 crates/workshop/src/gateway/identity.rs create mode 100644 crates/workshop/src/gateway/supervisor.rs create mode 100644 crates/workshop/src/gateway/tests.rs create mode 100644 crates/workshop/src/gateway/tests/boot.rs create mode 100644 crates/workshop/src/gateway/tests/identity.rs create mode 100644 crates/workshop/src/gateway/tests/recovery.rs create mode 100644 crates/workshop/tests/module_ceiling.rs diff --git a/Cargo.lock b/Cargo.lock index 74c8f4db..92d53efb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8295,6 +8295,7 @@ name = "workshop" version = "0.2.0" dependencies = [ "anyhow", + "serde", "serde_json", "shared-sidecar", "tauri", @@ -8306,6 +8307,7 @@ dependencies = [ "tauri-plugin-updater", "tauri-plugin-window-state", "tempfile", + "toml 0.8.2", "url", "webkit2gtk", "webview2-com", diff --git a/crates/workshop-server/Cargo.toml b/crates/workshop-server/Cargo.toml index 53dcc852..7d3a6cc1 100644 --- a/crates/workshop-server/Cargo.toml +++ b/crates/workshop-server/Cargo.toml @@ -43,10 +43,11 @@ tracing.workspace = true tracing-subscriber.workspace = true url.workspace = true promptforge-agent.workspace = true +tempfile = { workspace = true, optional = true } [features] default = [] -test-fixtures = [] +test-fixtures = ["dep:tempfile"] [dev-dependencies] workshop-server = { path = ".", features = ["test-fixtures"] } diff --git a/crates/workshop-server/module-ceilings.toml b/crates/workshop-server/module-ceilings.toml index efc59825..5f9903c3 100644 --- a/crates/workshop-server/module-ceilings.toml +++ b/crates/workshop-server/module-ceilings.toml @@ -244,10 +244,11 @@ "session/log.rs" = 15 "session/menu.rs" = 165 "status.rs" = 199 -# Crate-private named local process used to exercise production sidecar -# validation and observe authenticated shutdown without exposing a fixture -# capability constructor. +# Parent-side adapter for the shared named process fixture. Retains its +# pre-adapter ceiling rather than absorbing the child protocol. "test_gateway.rs" = 197 +# Child request protocol, exact bearer authentication, and shutdown reporting. +"test_gateway/process.rs" = 122 # Grew by grant revocation: `Workspace::revoke` (exact canonical match, # with a literal-key fallback so a deleted root stays revocable; nested # grants independent), the `POST /workspace/revoke` handler with its diff --git a/crates/workshop-server/src/fixtures.rs b/crates/workshop-server/src/fixtures.rs index 2d88bcf1..6352ebbd 100644 --- a/crates/workshop-server/src/fixtures.rs +++ b/crates/workshop-server/src/fixtures.rs @@ -11,6 +11,8 @@ pub use crate::status::StatusBus; #[cfg(feature = "test-fixtures")] pub use crate::app::fixtures::spawn_gateway; +#[cfg(feature = "test-fixtures")] +pub use crate::test_gateway::{ValidatedGateway, run_validated_gateway_fixture_process}; /// Returns the host-only Gateway publisher from fixture state. #[cfg(feature = "test-fixtures")] diff --git a/crates/workshop-server/src/lib.rs b/crates/workshop-server/src/lib.rs index f96cff8f..69797774 100644 --- a/crates/workshop-server/src/lib.rs +++ b/crates/workshop-server/src/lib.rs @@ -35,7 +35,7 @@ mod serve; mod session; mod session_agents; mod status; -#[cfg(test)] +#[cfg(any(test, feature = "test-fixtures"))] mod test_gateway; mod workspace; diff --git a/crates/workshop-server/src/test_gateway.rs b/crates/workshop-server/src/test_gateway.rs index d9dfe535..9bf8c54a 100644 --- a/crates/workshop-server/src/test_gateway.rs +++ b/crates/workshop-server/src/test_gateway.rs @@ -1,12 +1,14 @@ -//! Crate-private local Gateway process for capability tests. +//! Named local Gateway process shared by capability and supervision tests. -use std::io::{Read, Write as _}; +use std::io::Read; use std::net::{TcpListener, TcpStream}; use std::process::{Child, Command, Stdio}; use std::time::{Duration, Instant}; use shared_sidecar::{ConnectionFile, ValidatedConnection}; +mod process; + #[cfg(windows)] const GATEWAY_EXE_NAME: &str = "promptforge-gateway.exe"; #[cfg(not(windows))] @@ -15,7 +17,9 @@ const GATEWAY_EXE_NAME: &str = "promptforge-gateway"; const CONTROL_ADDRESS_ENV: &str = "PROMPTFORGE_TEST_GATEWAY_CONTROL_ADDRESS"; const EXPECTED_KEY_ENV: &str = "PROMPTFORGE_TEST_GATEWAY_EXPECTED_KEY"; -pub(crate) struct ValidatedGateway { +/// A named child Gateway that passes production process-image validation. +#[derive(Debug)] +pub struct ValidatedGateway { child: Child, port: u16, control: TcpStream, @@ -23,7 +27,28 @@ pub(crate) struct ValidatedGateway { } impl ValidatedGateway { + #[cfg(test)] pub(crate) fn spawn(expected_key: &str) -> Self { + Self::spawn_in( + expected_key, + "test_gateway::validated_gateway_fixture_process", + ) + } + + /// Starts the fixture in the current test binary through `fixture_test`. + /// + /// The named ignored test must call + /// [`run_validated_gateway_fixture_process`]. + /// + /// # Panics + /// Panics when the fixture listener, copied test image, child process, or + /// readiness handshake cannot be created. + #[must_use] + #[expect( + clippy::expect_used, + reason = "test fixture setup fails immediately with the failed invariant" + )] + pub fn spawn_in(expected_key: &str, fixture_test: &str) -> Self { let control = TcpListener::bind("127.0.0.1:0").expect("bind fixture control"); control .set_nonblocking(true) @@ -36,11 +61,7 @@ impl ValidatedGateway { ) .expect("copy test executable under the Gateway image name"); let mut child = Command::new(&executable) - .args([ - "--exact", - "test_gateway::validated_gateway_fixture_process", - "--ignored", - ]) + .args(["--exact", fixture_test, "--ignored"]) .env( CONTROL_ADDRESS_ENV, control @@ -84,26 +105,30 @@ impl ValidatedGateway { } } - pub(crate) const fn port(&self) -> u16 { + /// Returns the fixture's loopback port. + #[must_use] + pub const fn port(&self) -> u16 { self.port } - pub(crate) fn validate( - &self, - api_key: &str, - epoch: u64, - started_at: &str, - ) -> ValidatedConnection { + /// Produces a production-validated capability for this child. + /// + /// # Panics + /// Panics when the supplied bearer or boot identity does not validate + /// against the running fixture. + #[must_use] + #[expect( + clippy::expect_used, + reason = "test fixture validation fails immediately with the failed invariant" + )] + pub fn validate(&self, api_key: &str, epoch: u64, started_at: &str) -> ValidatedConnection { ValidatedConnection::validate(self.connection_file(api_key, epoch, started_at)) .expect("the named local Gateway validates") } - pub(crate) fn connection_file( - &self, - api_key: &str, - epoch: u64, - started_at: &str, - ) -> ConnectionFile { + /// Builds a connection file naming this child and the supplied boot data. + #[must_use] + pub fn connection_file(&self, api_key: &str, epoch: u64, started_at: &str) -> ConnectionFile { ConnectionFile { port: self.port, api_key: api_key.to_owned(), @@ -114,7 +139,16 @@ impl ValidatedGateway { } } - pub(crate) fn received_shutdown(&mut self, timeout: Duration) -> bool { + /// Waits up to `timeout` for an authenticated shutdown request. + /// + /// # Panics + /// Panics when the control socket cannot be configured or read, or when + /// the child sends an invalid control marker. + #[expect( + clippy::expect_used, + reason = "test fixture observation fails immediately with the failed invariant" + )] + pub fn received_shutdown(&mut self, timeout: Duration) -> bool { self.control .set_read_timeout(Some(timeout)) .expect("set fixture control timeout"); @@ -144,54 +178,20 @@ impl Drop for ValidatedGateway { } } +#[cfg(test)] #[test] #[ignore = "runs only as a named child process"] fn validated_gateway_fixture_process() { - let Ok(control_address) = std::env::var(CONTROL_ADDRESS_ENV) else { - return; - }; - let expected_key = - std::env::var(EXPECTED_KEY_ENV).expect("the fixture child receives an expected key"); - let listener = TcpListener::bind("127.0.0.1:0").expect("bind named fixture Gateway"); - let port = listener - .local_addr() - .expect("read named fixture address") - .port(); - let mut control = TcpStream::connect(control_address).expect("connect fixture control"); - control - .write_all(&port.to_be_bytes()) - .expect("announce named fixture readiness"); - - for stream in listener.incoming() { - let mut stream = stream.expect("accept named fixture request"); - while let Some(shutdown) = answer_request(&mut stream, &expected_key) { - if shutdown { - control - .write_all(&[1]) - .expect("report the accepted shutdown request"); - } - } - } + run_validated_gateway_fixture_process(); } -fn answer_request(stream: &mut TcpStream, expected_key: &str) -> Option { - let mut buffer = [0_u8; 4096]; - let Ok(read) = stream.read(&mut buffer) else { - return None; - }; - if read == 0 { - return None; - } - 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 { - "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}" - } else { - "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n" - }; - stream - .write_all(response.as_bytes()) - .is_ok() - .then(|| accepted && request.starts_with("POST /shutdown ")) +/// Runs the child half of [`ValidatedGateway`] inside an ignored test. +/// +/// This function never returns during a successful fixture run. +/// +/// # Panics +/// Panics when required fixture environment, listener, control connection, +/// request acceptance, or shutdown reporting cannot be established. +pub fn run_validated_gateway_fixture_process() { + process::run(); } diff --git a/crates/workshop-server/src/test_gateway/process.rs b/crates/workshop-server/src/test_gateway/process.rs new file mode 100644 index 00000000..85fc50a2 --- /dev/null +++ b/crates/workshop-server/src/test_gateway/process.rs @@ -0,0 +1,122 @@ +//! Child-process protocol for the named local Gateway fixture. + +use std::io::{Read as _, Write as _}; +use std::net::{TcpListener, TcpStream}; + +use super::{CONTROL_ADDRESS_ENV, EXPECTED_KEY_ENV}; + +/// Serves health, bearer, and shutdown requests inside the named child. +#[expect( + clippy::expect_used, + reason = "the isolated fixture process fails immediately with the failed invariant" +)] +pub(super) fn run() { + let Ok(control_address) = std::env::var(CONTROL_ADDRESS_ENV) else { + return; + }; + let expected_key = + std::env::var(EXPECTED_KEY_ENV).expect("the fixture child receives an expected key"); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind named fixture Gateway"); + let port = listener + .local_addr() + .expect("read named fixture address") + .port(); + let mut control = TcpStream::connect(control_address).expect("connect fixture control"); + control + .write_all(&port.to_be_bytes()) + .expect("announce named fixture readiness"); + + for stream in listener.incoming() { + let mut stream = stream.expect("accept named fixture request"); + let expected_key = expected_key.clone(); + let mut connection_control = control + .try_clone() + .expect("clone the fixture control connection"); + std::thread::spawn(move || { + while let Some(shutdown) = answer_request(&mut stream, expected_key.as_bytes()) { + if shutdown { + connection_control + .write_all(&[1]) + .expect("report the accepted shutdown request"); + } + } + }); + } +} + +fn answer_request(stream: &mut TcpStream, expected_key: &[u8]) -> Option { + let mut buffer = [0_u8; 4096]; + let Ok(read) = stream.read(&mut buffer) else { + return None; + }; + if read == 0 { + return None; + } + let request = &buffer[..read]; + let accepted = request.starts_with(b"GET /health ") || has_bearer(request, expected_key); + let response = if accepted { + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}" + } else { + "HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n" + }; + stream + .write_all(response.as_bytes()) + .is_ok() + .then(|| accepted && request.starts_with(b"POST /shutdown ")) +} + +fn has_bearer(request: &[u8], expected_key: &[u8]) -> bool { + request.split(|byte| *byte == b'\n').any(|line| { + let line = line.strip_suffix(b"\r").unwrap_or(line); + let Some(colon) = line.iter().position(|byte| *byte == b':') else { + return false; + }; + if !line[..colon].eq_ignore_ascii_case(b"authorization") { + return false; + } + let value = line[colon + 1..].trim_ascii_start(); + let Some(space) = value.iter().position(|byte| *byte == b' ') else { + return false; + }; + value[..space].eq_ignore_ascii_case(b"bearer") && &value[space + 1..] == expected_key + }) +} + +#[test] +fn bearer_credentials_are_byte_exact_and_case_sensitive() { + let expected = b"CaseSensitive-Key"; + assert!(has_bearer( + b"GET / HTTP/1.1\r\naUtHoRiZaTiOn: bEaReR CaseSensitive-Key\r\n\r\n", + expected + )); + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind credential regression"); + let address = listener.local_addr().expect("read regression address"); + let client = std::thread::spawn(move || { + let mut stream = TcpStream::connect(address).expect("connect credential regression"); + stream + .write_all( + b"GET /protected HTTP/1.1\r\nAuthorization: Bearer casesensitive-key\r\n\r\n", + ) + .expect("send wrong-case credential"); + stream + .shutdown(std::net::Shutdown::Write) + .expect("finish fixture request"); + let mut response = String::new(); + stream + .read_to_string(&mut response) + .expect("read fixture response"); + response + }); + let (mut server, _) = listener.accept().expect("accept credential regression"); + assert!( + !answer_request(&mut server, expected).expect("the fixture answers the request"), + "an unauthorized request cannot report shutdown" + ); + drop(server); + let response = client.join().expect("credential regression client joins"); + assert!( + response.starts_with("HTTP/1.1 401 Unauthorized"), + "the shared fixture rejects a bearer that differs only by case: {response}" + ); +} diff --git a/crates/workshop/Cargo.toml b/crates/workshop/Cargo.toml index 01674f20..b41f495a 100644 --- a/crates/workshop/Cargo.toml +++ b/crates/workshop/Cargo.toml @@ -51,7 +51,9 @@ tauri-build.workspace = true default = [] [dev-dependencies] +serde.workspace = true tempfile.workspace = true +toml.workspace = true workshop-server = { workspace = true, features = ["test-fixtures"] } # The test-fixtures feature exposes `resolve_for_test`, so the boot # decision tests run the real liveness gauntlet against the test binary's diff --git a/crates/workshop/README.md b/crates/workshop/README.md index 9cecc3c1..159257cd 100644 --- a/crates/workshop/README.md +++ b/crates/workshop/README.md @@ -2,7 +2,7 @@ [![License](https://img.shields.io/badge/license-BSL--1.0-blue.svg)](LICENSE) -The PromptForge Workshop desktop window. It hosts the workshop server in-process on a loopback listener with an OS-assigned port, waits for its health endpoint to answer, and opens a Tauri window (WebView2 on Windows) pointed at it. Boot connects the gateway first: attach to a running gateway through its connection file, or launch the sibling `promptforge-gateway` as a separate detached process when none is running. The server then resolves the same endpoint itself: the connection file first, explicit `workshop.toml` config second. Closing the window stops the in-process server only - the gateway is a separate process and keeps running; the window menu's quit item (Quit PromptForge and Gateway) also stops a local gateway. +The PromptForge Workshop desktop window. It hosts the workshop server in-process on a loopback listener with an OS-assigned port, waits for its health endpoint to answer, and opens a Tauri window (WebView2 on Windows) pointed at it. Boot connects the gateway first: attach to a running gateway through its connection file, or launch the sibling `promptforge-gateway` as a separate detached process when none is running. The server then resolves the same endpoint itself: the connection file first, explicit `workshop.toml` config second. While the window runs, the shell supervises only a local sidecar: it validates a replacement's process image, boot identity, health, and bearer before publishing the whole endpoint generation together, and relaunches the sibling with bounded backoff when no replacement exists. Closing the window cancels and joins supervision before stopping the in-process server; the gateway is a separate process and keeps running. The window menu's quit item (Quit PromptForge and Gateway) also stops the currently published local gateway. Explicitly configured LAN gateways are never supervised or stopped. ## Quick start diff --git a/crates/workshop/module-ceilings.toml b/crates/workshop/module-ceilings.toml new file mode 100644 index 00000000..4f8cd1d0 --- /dev/null +++ b/crates/workshop/module-ceilings.toml @@ -0,0 +1,30 @@ +# Module size ratchet for the Workshop binary and its tests. The +# `tests/module_ceiling.rs` integration test enforces this file. +# +# Counting rule: physical lines. Blank lines and comments count. A module +# may grow by at most 30 lines above its recorded measured size. Growth +# beyond that requires a responsibility split, not a ceiling increase. +# +# Every Rust file under src/ and tests/ has exactly one entry. New modules +# record their actual size in the same change, and deleted modules remove +# their stale entry. + +[source_modules] +"bridge.rs" = 372 +"config.rs" = 243 +"drops.rs" = 84 +"gateway.rs" = 15 +"gateway/boot.rs" = 184 +"gateway/identity.rs" = 41 +"gateway/supervisor.rs" = 360 +"gateway/tests.rs" = 140 +"gateway/tests/boot.rs" = 191 +"gateway/tests/identity.rs" = 44 +"gateway/tests/recovery.rs" = 445 +"linux_media.rs" = 42 +"main.rs" = 364 +"menu.rs" = 111 +"navigation.rs" = 125 + +[test_modules] +"module_ceiling.rs" = 147 diff --git a/crates/workshop/src/gateway.rs b/crates/workshop/src/gateway.rs index 8dfe6161..615a1481 100644 --- a/crates/workshop/src/gateway.rs +++ b/crates/workshop/src/gateway.rs @@ -1,1509 +1,15 @@ -//! Attach-or-launch: the shell's sidecar gateway lifecycle. +//! Attach-or-launch lifecycle for the desktop shell's Gateway sidecar. //! -//! Boot resolves the gateway's connection file first: a live file means a -//! gateway is already running and the shell attaches (the in-process -//! server resolves the same file). With no live file, a sibling -//! `promptforge-gateway` beside the shell's own executable is launched -//! detached - `CREATE_BREAKAWAY_FROM_JOB` on Windows so the gateway -//! survives the shell's exit and any job object; never -//! tauri-plugin-shell's sidecar API, which kills its children on exit - -//! through `shared_sidecar`'s launch lock, so two racing shells elect one -//! launcher and the loser attaches. A Workshop-only install has no -//! sibling executable: resolution falls through to explicit -//! `workshop.toml` `[gateway]` config (a LAN gateway), and with neither -//! boot fails loud naming both remedies. -//! -//! The shell never reads `gateway.toml`, never deletes `gateway.json`, -//! and never kills the gateway on exit; the quit-everything menu item -//! (`crate::menu`) is the only path that stops the Gateway, through the -//! server's current validated binding snapshot. - -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; - -use anyhow::Context as _; -use shared_sidecar::{ - CancellationToken, ConnectionFile, LaunchDecision, Resolution, SidecarError, - ValidatedConnection, -}; -use workshop_server::Config; - -/// The sibling executable the shell launches, beside its own. -#[cfg(windows)] -const GATEWAY_EXE_NAME: &str = "promptforge-gateway.exe"; -/// The sibling executable the shell launches, beside its own. -#[cfg(not(windows))] -const GATEWAY_EXE_NAME: &str = "promptforge-gateway"; - -/// The budget for each leg of the launch path: the launch race, then -/// the wait for the connection file to appear and answer health. A -/// first boot also generates the default config, so this is generous. -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); - -/// Maximum designed supervisor shutdown latency. -const SUPERVISOR_SHUTDOWN_BUDGET: Duration = Duration::from_secs(3); - -/// 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 { - cancellation: CancellationToken, - thread: Option>, -} - -impl GatewaySupervisor { - fn spawn(supervise: impl FnOnce(CancellationToken) + Send + 'static) -> anyhow::Result { - let cancellation = CancellationToken::new(); - let worker_cancellation = cancellation.clone(); - let thread = std::thread::Builder::new() - .name("gateway-supervisor".to_owned()) - .spawn(move || supervise(worker_cancellation)) - .context("spawn the gateway supervisor")?; - Ok(Self { - cancellation, - thread: Some(thread), - }) - } - - /// Cancels supervision and joins its thread. - pub(crate) fn shutdown(mut self) { - self.cancel_and_join(); - } - - fn cancel_and_join(&mut self) { - let started = Instant::now(); - self.cancellation.cancel(); - if let Some(thread) = self.thread.take() - && thread.join().is_err() - { - eprintln!("the gateway supervisor panicked during shutdown"); - } - let elapsed = started.elapsed(); - if elapsed > SUPERVISOR_SHUTDOWN_BUDGET { - eprintln!( - "the gateway supervisor exceeded its {SUPERVISOR_SHUTDOWN_BUDGET:?} shutdown budget: {elapsed:?}" - ); - } - } -} - -impl Drop for GatewaySupervisor { - fn drop(&mut self) { - self.cancel_and_join(); - } -} - -/// How boot connected the Gateway: the fact the quit-everything menu labels -/// from and the supervisor uses to decide whether it owns local recovery. -#[derive(Debug)] -pub(crate) enum GatewayAttachment { - /// A local sidecar gateway the shell attached to or launched: - /// quit-everything posts its `/shutdown`. - Sidecar(ConnectionFile), - /// An explicit-config (LAN) gateway: quit stops the shell only. - Config, -} - -impl GatewayAttachment { - /// The initial connection file of a sidecar attachment. - pub(crate) fn sidecar_file(&self) -> Option<&ConnectionFile> { - match self { - Self::Sidecar(file) => Some(file), - Self::Config => None, - } - } -} - -/// What the boot decision concluded. -#[derive(Debug, PartialEq, Eq)] -enum GatewayPlan { - /// A live connection file exists: attach, launch nothing. - Attach(ConnectionFile), - /// No live file, and a sibling gateway executable exists: launch it. - Launch(PathBuf), - /// No live file and no sibling executable, but explicit config: the - /// server attaches to the configured (LAN) gateway. - ConfigOnly, - /// Nothing to attach to and nothing to launch: fail loud. - Fail, -} - -/// Connects the gateway for boot: attach to a live one, launch the -/// sibling executable when there is none, or fall back to explicit -/// `workshop.toml` config. -/// -/// # Errors -/// Returns an error when nothing can connect (no live file, no sibling -/// executable, no explicit config) or when the launch path fails: the -/// race, the spawn, or the launched gateway never becoming healthy. -pub(crate) fn ensure_gateway(config: &Config) -> anyhow::Result { - let exe_dir = std::env::current_exe() - .context("locate the executable") - .and_then(|exe| { - exe.parent() - .map(Path::to_path_buf) - .context("the executable has no parent directory") - })?; - let explicit = !config.gateway.base_url.is_empty(); - let Some(run_dir) = shared_sidecar::default_run_dir() else { - // Discovery and launch both live in the run directory; without a - // profile directory only explicit config can connect. - return if explicit { - Ok(GatewayAttachment::Config) - } else { - Err(no_gateway_error()) - }; - }; - match plan_gateway(&run_dir, &exe_dir, explicit, shared_sidecar::resolve) { - GatewayPlan::Attach(file) => Ok(GatewayAttachment::Sidecar(file)), - GatewayPlan::ConfigOnly => Ok(GatewayAttachment::Config), - GatewayPlan::Fail => Err(no_gateway_error()), - GatewayPlan::Launch(exe) => launch_and_attach(&run_dir, &exe) - .map(GatewayAttachment::Sidecar) - .context("launch the sidecar gateway"), - } -} - -/// The boot decision with the environment injected: resolve the -/// connection file in `run_dir` with `resolve`, probe for the sibling -/// executable in `exe_dir`, and read whether the config names a gateway -/// explicitly. -fn plan_gateway( - run_dir: &Path, - exe_dir: &Path, - explicit_config: bool, - resolve: fn(&Path) -> Result, -) -> GatewayPlan { - match resolve(run_dir) { - Ok(Resolution::Attach(file)) => return GatewayPlan::Attach(file), - Ok(_) => {} - // An unreadable run directory must not read as "no gateway": the - // launch lock's own re-validation settles whether a launch is - // safe, and the config fallback matches the server's - // degrade-on-probe-error rule. - Err(error) => { - eprintln!("could not resolve the gateway connection file: {error}"); - } - } - match sibling_gateway(exe_dir) { - Some(exe) => GatewayPlan::Launch(exe), - None if explicit_config => GatewayPlan::ConfigOnly, - None => GatewayPlan::Fail, - } -} - -/// The sibling gateway executable beside the shell's own, when the -/// installer laid one down (a Gateway or full install; absent on a -/// Workshop-only install). -fn sibling_gateway(exe_dir: &Path) -> Option { - let candidate = exe_dir.join(GATEWAY_EXE_NAME); - candidate.is_file().then_some(candidate) -} - -/// The loud boot failure when nothing can connect: names both remedies. -fn no_gateway_error() -> anyhow::Error { - anyhow::anyhow!( - "no gateway configured or running; install the Gateway component so \ - promptforge-gateway sits beside the workshop executable, or set \ - gateway.base_url and gateway.api_key in workshop.toml to attach to \ - a gateway over the network" - ) -} - -/// The launch path: take the launch lock (a racing shell attaches to the -/// winner instead), spawn the sibling gateway detached, and wait for its -/// connection file and health. -fn launch_and_attach(run_dir: &Path, exe: &Path) -> anyhow::Result { - match shared_sidecar::launch_or_attach(run_dir, LAUNCH_TIMEOUT) - .context("settle the gateway launch race")? - { - LaunchDecision::Attach(file) => Ok(file), - LaunchDecision::Launch(lock) => { - spawn_detached(exe).with_context(|| format!("spawn {}", exe.display()))?; - // The lock stays held across the wait: a racing shell - // attaches to the file the spawn writes rather than - // launching a second gateway. Dropping the guard releases it. - let file = wait_for_launched_file(run_dir, LAUNCH_TIMEOUT)?; - drop(lock); - Ok(file) - } - // `LaunchDecision` is non-exhaustive; a variant this build does - // not know fails the boot rather than guessing at it. - decision => anyhow::bail!("an unknown launch decision: {decision:?}"), - } -} - -fn launch_and_attach_cancellable( - run_dir: &Path, - exe: &Path, - cancellation: &CancellationToken, -) -> anyhow::Result { - launch_and_attach_cancellable_with( - run_dir, - exe, - cancellation, - shared_sidecar::launch_or_attach_cancellable, - |exe, _| spawn_detached(exe), - wait_for_launched_file_cancellable, - ) -} +//! Boot planning and one-shot launch, validated identity, and continuous +//! supervision are private sibling modules with one-way dependencies. -fn launch_and_attach_cancellable_with( - run_dir: &Path, - exe: &Path, - cancellation: &CancellationToken, - settle: Settle, - spawn: Spawn, - wait: Wait, -) -> anyhow::Result -where - Settle: FnOnce(&Path, Duration, &CancellationToken) -> Result, - Spawn: FnOnce(&Path, &CancellationToken) -> std::io::Result<()>, - Wait: FnOnce(&Path, Duration, &CancellationToken) -> anyhow::Result, -{ - match settle(run_dir, LAUNCH_TIMEOUT, cancellation).context("settle the gateway launch race")? { - LaunchDecision::Attach(file) => { - if cancellation.is_cancelled() { - anyhow::bail!("gateway attachment was cancelled"); - } - Ok(file) - } - LaunchDecision::Launch(lock) => { - run_effect_if_active(cancellation, "gateway launch", |cancellation| { - if cancellation.is_cancelled() { - anyhow::bail!("gateway launch was cancelled"); - } - spawn(exe, cancellation).with_context(|| format!("spawn {}", exe.display())) - })?; - let file = wait(run_dir, LAUNCH_TIMEOUT, cancellation)?; - drop(lock); - Ok(file) - } - decision => anyhow::bail!("an unknown launch decision: {decision:?}"), - } -} +mod boot; +mod identity; +mod supervisor; -fn run_effect_if_active( - cancellation: &CancellationToken, - phase: &'static str, - operation: impl FnOnce(&CancellationToken) -> anyhow::Result, -) -> anyhow::Result { - match cancellation.run_if_active(|| operation(cancellation)) { - Some(result) => result, - None => anyhow::bail!("{phase} was cancelled"), - } -} - -/// Waits for the launched gateway's connection file to appear, become ready, -/// and pass the shared process-image, health, and bearer validation. -fn wait_for_launched_file(run_dir: &Path, timeout: Duration) -> 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; - loop { - if let Ok(Some(file)) = ConnectionFile::read(run_dir) { - 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 validated connection file within {timeout:?}" - ); - } - std::thread::sleep(POLL_INTERVAL); - } -} - -fn wait_for_launched_file_cancellable( - run_dir: &Path, - timeout: Duration, - cancellation: &CancellationToken, -) -> anyhow::Result { - wait_for_launched_file_cancellable_with( - run_dir, - timeout, - cancellation, - shared_sidecar::wait_for_health_cancellable, - shared_sidecar::resolve_cancellable, - ) -} - -fn wait_for_launched_file_cancellable_with( - run_dir: &Path, - timeout: Duration, - cancellation: &CancellationToken, - mut health: Health, - mut resolve: Resolve, -) -> anyhow::Result -where - Health: FnMut(&str, Duration, &CancellationToken) -> Result<(), shared_sidecar::HealthError>, - Resolve: FnMut(&Path, &CancellationToken) -> Result, -{ - let deadline = Instant::now() + timeout; - loop { - if cancellation.is_cancelled() { - anyhow::bail!("the launched gateway wait was cancelled"); - } - if let Ok(Some(file)) = ConnectionFile::read(run_dir) { - let remaining = deadline.saturating_duration_since(Instant::now()); - let url = format!("http://127.0.0.1:{}", file.port); - health(&url, remaining, cancellation) - .context("the launched gateway did not answer its health probe")?; - if cancellation.is_cancelled() { - anyhow::bail!("the launched gateway wait was cancelled"); - } - match resolve(run_dir, cancellation) { - Ok(Resolution::Attach(validated)) => { - if cancellation.is_cancelled() { - anyhow::bail!("the launched gateway wait was cancelled"); - } - return Ok(validated); - } - Err(SidecarError::Cancelled) => { - anyhow::bail!("the launched gateway wait was cancelled"); - } - Ok(_) | Err(_) => {} - } - } - if cancellation.is_cancelled() { - anyhow::bail!("the launched gateway wait was cancelled"); - } - if Instant::now() >= deadline { - anyhow::bail!( - "the launched gateway wrote no validated connection file within {timeout:?}" - ); - } - if cancellation.wait_timeout(POLL_INTERVAL) { - anyhow::bail!("the launched gateway wait was cancelled"); - } - } -} - -/// 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 - .stdin(std::process::Stdio::null()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()); - #[cfg(windows)] - { - use std::os::windows::process::CommandExt as _; - // Break out of any job object whose kill-on-close would reap the - // gateway with the shell, take no console (the gateway is a - // console-subsystem binary spawned from a GUI process), and leave - // the shell's Ctrl-C group. - const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000; - const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; - const DETACHED_PROCESS: u32 = 0x0000_0008; - command.creation_flags( - CREATE_BREAKAWAY_FROM_JOB | CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS, - ); - } - // A new process group keeps a terminal's Ctrl-C (SIGINT to the - // shell's group) from reaching the gateway. - #[cfg(unix)] - { - use std::os::unix::process::CommandExt as _; - command.process_group(0); - } - let mut child = command.spawn()?; - // Reap the child when it eventually exits; a detached child nobody - // waits on lingers as a zombie for the shell's whole lifetime. When - // the reaper thread cannot be spawned the child goes unreaped - the - // benign outcome the thread exists to prevent - so report and boot - // on rather than panic out of the designed loud boot-failure path. - if let Err(error) = std::thread::Builder::new().spawn(move || { - let _ = child.wait(); - }) { - eprintln!("could not spawn the gateway reaper thread; the child goes unreaped: {error}"); - } - Ok(()) -} - -fn validate_and_publish_with( - file: &ConnectionFile, - cancellation: &CancellationToken, - validate: impl FnOnce(&ConnectionFile, &CancellationToken) -> anyhow::Result, - publish: impl FnOnce(&T, &CancellationToken) -> anyhow::Result<()>, -) -> anyhow::Result<()> { - let validated = validate(file, cancellation)?; - if cancellation.is_cancelled() { - anyhow::bail!("gateway publication was cancelled"); - } - publish(&validated, cancellation) -} - -/// 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, -) -> 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); - GatewaySupervisor::spawn(move |cancellation| { - run_supervision( - initial, - |_, cancellation| match shared_sidecar::resolve_cancellable(&run_dir, cancellation) { - Ok(Resolution::Attach(file)) => SupervisionProbe::Replacement(file), - Ok(_) | Err(SidecarError::Cancelled) => SupervisionProbe::Missing, - Err(error) => { - eprintln!("could not re-resolve the local gateway: {error}"); - SupervisionProbe::Missing - } - }, - |cancellation| { - let exe = sibling.as_deref().context( - "the local gateway disappeared and no sibling gateway executable is installed", - )?; - launch_and_attach_cancellable(&run_dir, exe, cancellation) - }, - |file, cancellation| { - validate_and_publish_with( - file, - cancellation, - |file, cancellation| { - ValidatedConnection::validate_cancellable(file.clone(), cancellation) - .context("validate the replacement gateway identity") - }, - |validated, cancellation| { - if updater - .replace_sidecar_cancellable(validated, cancellation) - .context("publish the replacement gateway endpoint")? - { - Ok(()) - } else { - anyhow::bail!("gateway publication was cancelled") - } - }, - ) - }, - |delay, cancellation| cancellation.wait_timeout(delay), - &cancellation, - ); - }) - .map(Some) -} - -/// 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, - cancellation: &CancellationToken, -) where - Probe: FnMut(&ConnectionFile, &CancellationToken) -> SupervisionProbe, - Recover: FnMut(&CancellationToken) -> Result, - Publish: FnMut(&ConnectionFile, &CancellationToken) -> Result<(), Error>, - Wait: FnMut(Duration, &CancellationToken) -> bool, - Error: std::fmt::Display, -{ - let mut retry_delay = SUPERVISION_BASE_DELAY; - loop { - if cancellation.is_cancelled() { - return; - } - let observation = probe(¤t, cancellation); - if cancellation.is_cancelled() { - return; - } - match observation { - SupervisionProbe::Replacement(file) if same_gateway_identity(&file, ¤t) => { - retry_delay = SUPERVISION_BASE_DELAY; - if wait(SUPERVISION_INTERVAL, cancellation) { - return; - } - continue; - } - SupervisionProbe::Replacement(file) => match publish(&file, cancellation) { - Ok(()) => { - if cancellation.is_cancelled() { - return; - } - current = file; - retry_delay = SUPERVISION_BASE_DELAY; - continue; - } - Err(error) => { - eprintln!("could not publish a replacement local gateway: {error}"); - } - }, - SupervisionProbe::Missing => match recover(cancellation) { - Ok(_) if cancellation.is_cancelled() => return, - Ok(file) => match publish(&file, cancellation) { - Ok(()) => { - if cancellation.is_cancelled() { - return; - } - 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 cancellation.is_cancelled() || wait(retry_delay, cancellation) { - 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 -} +pub(crate) use boot::ensure_gateway; +pub(crate) use identity::GatewayAttachment; +pub(crate) use supervisor::{GatewaySupervisor, supervise}; #[cfg(test)] -mod tests { - use super::*; - - use std::io::{Read, Write as _}; - use std::net::{TcpListener, TcpStream}; - use std::process::{Child, Command, Stdio}; - use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; - use std::sync::{Arc, mpsc}; - - /// The test process's own image name, so the probe's pid and image - /// checks pass and the test reaches the liveness probes. - fn own_image_name() -> String { - std::env::current_exe() - .expect("current exe") - .file_name() - .expect("the exe has a file name") - .to_string_lossy() - .into_owned() - } - - /// A probe running the real liveness gauntlet against the test - /// binary's own image. - fn probe_own_image(run_dir: &Path) -> Result { - shared_sidecar::resolve_for_test(run_dir, &own_image_name()) - } - - /// A probe whose run directory is broken: a directory sits where the - /// connection file belongs, so the read errors instead of answering. - fn probe_read_failure(run_dir: &Path) -> Result { - std::fs::create_dir(shared_sidecar::connection_file_path(run_dir)) - .expect("plant the unreadable file"); - probe_own_image(run_dir) - } - - /// A connection file pointing at the test process itself. - fn live_file(port: u16, api_key: &str) -> ConnectionFile { - ConnectionFile { - port, - api_key: api_key.to_owned(), - pid: std::process::id(), - epoch: 1_757_000_000, - version: "0.2.0".to_owned(), - started_at: "2026-09-03T12:00:00Z".to_owned(), - } - } - - /// A pid guaranteed dead: a short-lived child, reaped and dropped so - /// no handle keeps the process object alive. - fn dead_pid() -> u32 { - let mut child = std::process::Command::new(std::env::current_exe().expect("current exe")) - .arg("--list") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - .expect("spawn a short-lived child"); - let pid = child.id(); - child.wait().expect("the child exits"); - drop(child); - pid - } - - /// A fixture gateway: answers `GET /health` with 200 and the key - /// probe with 200 only when the bearer matches `expected_key`. - fn fixture_gateway(expected_key: impl Into) -> u16 { - let expected_key = Arc::new(expected_key.into()); - 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() { - let expected_key = Arc::clone(&expected_key); - std::thread::spawn(move || { - loop { - let mut buffer = [0u8; 1024]; - let Ok(read) = stream.read(&mut buffer) else { - break; - }; - if read == 0 { - break; - } - let request = String::from_utf8_lossy(&buffer[..read]); - let accepted = request.starts_with("GET /health ") - || request.lines().any(|line| { - line.eq_ignore_ascii_case(&format!( - "Authorization: Bearer {expected_key}" - )) - }); - 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 - } - - const CONTROL_ADDRESS_ENV: &str = "PROMPTFORGE_TEST_GATEWAY_CONTROL_ADDRESS"; - const EXPECTED_KEY_ENV: &str = "PROMPTFORGE_TEST_GATEWAY_EXPECTED_KEY"; - - struct NamedGateway { - child: Child, - port: u16, - _directory: tempfile::TempDir, - } - - impl NamedGateway { - fn spawn(expected_key: &str) -> Self { - let control = TcpListener::bind("127.0.0.1:0").expect("bind fixture control"); - control - .set_nonblocking(true) - .expect("make fixture control nonblocking"); - let directory = tempfile::TempDir::new().expect("create fixture executable directory"); - let executable = directory.path().join(GATEWAY_EXE_NAME); - std::fs::copy( - std::env::current_exe().expect("locate test executable"), - &executable, - ) - .expect("copy test executable under the Gateway image name"); - let mut child = Command::new(&executable) - .args([ - "--exact", - "gateway::tests::validated_gateway_fixture_process", - "--ignored", - ]) - .env( - CONTROL_ADDRESS_ENV, - control - .local_addr() - .expect("read fixture control address") - .to_string(), - ) - .env(EXPECTED_KEY_ENV, expected_key) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .expect("start the named Gateway fixture process"); - let deadline = Instant::now() + Duration::from_secs(10); - let mut stream = loop { - match control.accept() { - Ok((stream, _)) => break stream, - Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { - assert!( - child.try_wait().expect("observe fixture process").is_none(), - "the named Gateway fixture exited before becoming ready" - ); - assert!( - Instant::now() < deadline, - "the named Gateway fixture did not become ready" - ); - std::thread::sleep(Duration::from_millis(10)); - } - Err(error) => panic!("accept fixture control connection: {error}"), - } - }; - let mut port = [0_u8; 2]; - stream - .read_exact(&mut port) - .expect("read fixture Gateway port"); - Self { - child, - port: u16::from_be_bytes(port), - _directory: directory, - } - } - - fn connection_file(&self, api_key: &str, epoch: u64, started_at: &str) -> ConnectionFile { - ConnectionFile { - port: self.port, - api_key: api_key.to_owned(), - pid: self.child.id(), - epoch, - version: "test".to_owned(), - started_at: started_at.to_owned(), - } - } - } - - impl Drop for NamedGateway { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } - } - - #[test] - #[ignore = "runs only as a named child process"] - fn validated_gateway_fixture_process() { - let Ok(control_address) = std::env::var(CONTROL_ADDRESS_ENV) else { - return; - }; - let expected_key = - std::env::var(EXPECTED_KEY_ENV).expect("the fixture child receives an expected key"); - let port = fixture_gateway(expected_key); - TcpStream::connect(control_address) - .and_then(|mut stream| stream.write_all(&port.to_be_bytes())) - .expect("announce named fixture readiness"); - loop { - std::thread::park(); - } - } - - fn get(url: &str, path: &str) -> String { - let url = url::Url::parse(url).expect("the Workshop URL parses"); - let host = url.host_str().expect("the Workshop URL has a host"); - let port = url.port().expect("the Workshop URL has a port"); - let mut stream = TcpStream::connect((host, port)).expect("connect to Workshop"); - write!( - stream, - "GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n" - ) - .expect("send Workshop request"); - let mut response = String::new(); - stream - .read_to_string(&mut response) - .expect("read Workshop response"); - response - } - - /// An executable directory, with or without the sibling gateway. - fn exe_dir(with_gateway: bool) -> (tempfile::TempDir, PathBuf) { - let dir = tempfile::TempDir::new().expect("tempdir"); - if with_gateway { - std::fs::write(dir.path().join(GATEWAY_EXE_NAME), b"").expect("plant the sibling exe"); - } - let path = dir.path().to_owned(); - (dir, path) - } - - #[test] - fn a_live_file_attaches_without_looking_for_a_sibling_exe() { - let run = tempfile::TempDir::new().expect("tempdir"); - let port = fixture_gateway("key"); - let file = live_file(port, "key"); - file.write_to(run.path()).expect("write"); - // No sibling exe and no explicit config: attach must not consult - // either. - let (_exe, exe_dir) = exe_dir(false); - - match plan_gateway(run.path(), &exe_dir, false, probe_own_image) { - GatewayPlan::Attach(attached) => assert_eq!(attached, file), - other => panic!("a live gateway must be attached, not {other:?}"), - } - } - - #[test] - fn no_file_and_a_sibling_exe_launches() { - let run = tempfile::TempDir::new().expect("tempdir"); - let (_exe, exe_dir) = exe_dir(true); - - match plan_gateway(run.path(), &exe_dir, false, probe_own_image) { - GatewayPlan::Launch(exe) => assert_eq!(exe, exe_dir.join(GATEWAY_EXE_NAME)), - other => panic!("a full install with no running gateway must launch, not {other:?}"), - } - } - - #[test] - fn no_file_and_no_sibling_exe_falls_through_to_explicit_config() { - let run = tempfile::TempDir::new().expect("tempdir"); - let (_exe, exe_dir) = exe_dir(false); - - let plan = plan_gateway(run.path(), &exe_dir, true, probe_own_image); - assert_eq!( - plan, - GatewayPlan::ConfigOnly, - "a Workshop-only install attaches to the configured LAN gateway" - ); - } - - #[test] - fn no_file_no_sibling_exe_and_no_config_fails() { - let run = tempfile::TempDir::new().expect("tempdir"); - let (_exe, exe_dir) = exe_dir(false); - - let plan = plan_gateway(run.path(), &exe_dir, false, probe_own_image); - assert_eq!( - plan, - GatewayPlan::Fail, - "nothing to connect to must fail loud, not serve a broken window" - ); - } - - #[test] - fn a_stale_file_is_cleaned_and_the_sibling_exe_launches() { - let run = tempfile::TempDir::new().expect("tempdir"); - let file = ConnectionFile { - pid: dead_pid(), - ..live_file(1, "k") - }; - file.write_to(run.path()).expect("write"); - let (_exe, exe_dir) = exe_dir(true); - - let plan = plan_gateway(run.path(), &exe_dir, false, probe_own_image); - assert!( - matches!(plan, GatewayPlan::Launch(_)), - "a stale file must not block the relaunch: {plan:?}" - ); - assert!( - !shared_sidecar::connection_file_path(run.path()).exists(), - "the stale file was cleaned" - ); - } - - #[test] - fn a_stale_file_with_no_sibling_exe_falls_through_to_explicit_config() { - let run = tempfile::TempDir::new().expect("tempdir"); - let file = ConnectionFile { - pid: dead_pid(), - ..live_file(1, "k") - }; - file.write_to(run.path()).expect("write"); - let (_exe, exe_dir) = exe_dir(false); - - let plan = plan_gateway(run.path(), &exe_dir, true, probe_own_image); - assert_eq!( - plan, - GatewayPlan::ConfigOnly, - "a stale file must not wedge the LAN fallback" - ); - assert!( - !shared_sidecar::connection_file_path(run.path()).exists(), - "the stale file was cleaned" - ); - } - - #[test] - fn a_resolve_error_still_launches_the_sibling_exe() { - let run = tempfile::TempDir::new().expect("tempdir"); - let (_exe, exe_dir) = exe_dir(true); - - let plan = plan_gateway(run.path(), &exe_dir, false, probe_read_failure); - assert!( - matches!(plan, GatewayPlan::Launch(_)), - "a discovery error must not read as no-gateway: {plan:?}" - ); - } - - #[test] - fn the_sibling_probe_finds_only_the_gateway_exe_beside_the_shell() { - let (_dir, with) = exe_dir(true); - assert_eq!( - sibling_gateway(&with), - Some(with.join(GATEWAY_EXE_NAME)), - "the installed sibling is found" - ); - let (_dir, without) = exe_dir(false); - assert_eq!( - sibling_gateway(&without), - None, - "a Workshop-only install has no sibling" - ); - } - - #[test] - fn the_launch_wait_returns_once_the_file_appears_and_answers() { - let run = tempfile::TempDir::new().expect("tempdir"); - let port = fixture_gateway("key"); - let file = live_file(port, "key"); - let run_path = run.path().to_owned(); - let written = file.clone(); - let writer = std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(100)); - written - .write_to(&run_path) - .expect("the launched gateway writes"); - }); - - 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"); - } - - #[test] - fn the_launch_wait_times_out_when_no_file_appears() { - let run = tempfile::TempDir::new().expect("tempdir"); - 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 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_local_sidecar_file() { - let file = live_file(1, "k"); - let sidecar = GatewayAttachment::Sidecar(file.clone()); - assert_eq!( - sidecar.sidecar_file(), - Some(&file), - "a sidecar attachment carries its initial local connection file" - ); - let config = GatewayAttachment::Config; - assert_eq!( - config.sidecar_file(), - None, - "a LAN Gateway from explicit config carries no local sidecar identity" - ); - } - - #[test] - fn the_no_gateway_error_names_both_remedies() { - let message = no_gateway_error().to_string(); - assert!( - message.contains("promptforge-gateway"), - "the error names the Gateway component remedy: {message}" - ); - assert!( - message.contains("workshop.toml"), - "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 gateway = NamedGateway::spawn("new-key"); - let original = gateway.connection_file("old-key", 1_757_000_000, "2026-09-03T12:00:00Z"); - let replacement = gateway.connection_file("new-key", 1_757_000_001, "2026-09-03T12:00:01Z"); - let state_dir = tempfile::TempDir::new().expect("create Workshop state directory"); - let server = workshop_server::fixtures::spawn(workshop_server::Config { - gateway: workshop_server::GatewayConfig { - base_url: format!("http://127.0.0.1:{}", original.port), - api_key: original.api_key.clone(), - }, - server: workshop_server::ServerConfig { - bind: "127.0.0.1:0".to_owned(), - open_browser: false, - state_dir: state_dir.path().to_owned(), - }, - agents: workshop_server::AgentsConfig::default(), - }) - .expect("spawn Workshop against the original same-port key"); - let updater = server.gateway_updater(); - let publish_replacement = |file: &ConnectionFile| -> anyhow::Result<()> { - let validated = ValidatedConnection::validate(file.clone()) - .context("validate the named local Gateway")?; - updater - .replace_sidecar(&validated) - .context("publish through the production updater") - }; - let elapsed = Cell::new(Duration::ZERO); - let recoveries = Cell::new(0_u8); - let published = RefCell::new(Vec::new()); - let cancellation = CancellationToken::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, _| { - publish_replacement(file)?; - published.borrow_mut().push(file.clone()); - Ok::<(), anyhow::Error>(()) - }, - |delay, _| { - assert!( - delay <= SUPERVISION_MAX_DELAY, - "every supervision wait is capped: {delay:?}" - ); - elapsed.set(elapsed.get() + delay); - !published.borrow().is_empty() - }, - &cancellation, - ); - - 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" - ); - let response = get(server.url(), "/gateway/api/admin/status"); - assert!( - response.starts_with("HTTP/1.1 200"), - "the real publisher replaces the bearer on the reused port: {response}" - ); - server.shutdown().expect("stop the Workshop fixture"); - } - - #[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()); - let cancellation = CancellationToken::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(), - &cancellation, - ); - - 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)); - } - - fn assert_bounded_supervisor_shutdown(supervisor: GatewaySupervisor, finished: &AtomicBool) { - let started = Instant::now(); - supervisor.shutdown(); - assert!( - started.elapsed() < Duration::from_millis(250), - "Workshop exit joins the cancelled supervisor within its budget" - ); - assert!( - finished.load(Ordering::SeqCst), - "shutdown returns only after the supervisor thread exits" - ); - } - - #[test] - fn exit_joins_a_supervisor_blocked_in_resolve_before_recovery() { - let (entered, blocked) = mpsc::channel(); - let recoveries = Arc::new(AtomicUsize::new(0)); - let worker_recoveries = Arc::clone(&recoveries); - let finished = Arc::new(AtomicBool::new(false)); - let worker_finished = Arc::clone(&finished); - let supervisor = GatewaySupervisor::spawn(move |cancellation| { - run_supervision( - live_file(54_375, "stable-key"), - |_, cancellation| { - entered.send(()).expect("announce blocked resolve"); - let _ = cancellation.wait_timeout(Duration::from_secs(30)); - SupervisionProbe::Missing - }, - |_| { - worker_recoveries.fetch_add(1, Ordering::SeqCst); - anyhow::bail!("recovery must not start after cancellation") - }, - |_, _| Ok::<(), anyhow::Error>(()), - |delay, cancellation| cancellation.wait_timeout(delay), - &cancellation, - ); - worker_finished.store(true, Ordering::SeqCst); - }) - .expect("spawn test supervisor"); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("the resolve phase blocks deterministically"); - - assert_bounded_supervisor_shutdown(supervisor, &finished); - assert_eq!( - recoveries.load(Ordering::SeqCst), - 0, - "cancellation prevents every later recovery launch" - ); - } - - #[test] - fn exit_wakes_the_supervision_wait_without_a_later_probe() { - let (entered, blocked) = mpsc::channel(); - let probes = Arc::new(AtomicUsize::new(0)); - let worker_probes = Arc::clone(&probes); - let finished = Arc::new(AtomicBool::new(false)); - let worker_finished = Arc::clone(&finished); - let supervisor = GatewaySupervisor::spawn(move |cancellation| { - run_supervision( - live_file(54_375, "stable-key"), - |current, _| { - worker_probes.fetch_add(1, Ordering::SeqCst); - SupervisionProbe::Replacement(current.clone()) - }, - |_| -> anyhow::Result { - panic!("a healthy Gateway does not recover") - }, - |_, _| Ok::<(), anyhow::Error>(()), - |delay, cancellation| { - entered.send(()).expect("announce supervision wait"); - cancellation.wait_timeout(delay) - }, - &cancellation, - ); - worker_finished.store(true, Ordering::SeqCst); - }) - .expect("spawn test supervisor"); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("the supervision wait blocks deterministically"); - - assert_bounded_supervisor_shutdown(supervisor, &finished); - assert_eq!( - probes.load(Ordering::SeqCst), - 1, - "cancellation prevents every later liveness probe" - ); - } - - #[test] - fn exit_joins_a_supervisor_blocked_in_validation_before_publication() { - let (entered, blocked) = mpsc::channel(); - let publications = Arc::new(AtomicUsize::new(0)); - let worker_publications = Arc::clone(&publications); - let finished = Arc::new(AtomicBool::new(false)); - let worker_finished = Arc::clone(&finished); - let supervisor = GatewaySupervisor::spawn(move |cancellation| { - let result = validate_and_publish_with( - &live_file(54_375, "stable-key"), - &cancellation, - |_, cancellation| -> anyhow::Result<()> { - entered.send(()).expect("announce blocked validation"); - let _ = cancellation.wait_timeout(Duration::from_secs(30)); - anyhow::bail!("validation cancelled") - }, - |(), _| { - worker_publications.fetch_add(1, Ordering::SeqCst); - Ok(()) - }, - ); - assert!(result.is_err(), "the cancelled validation is rejected"); - worker_finished.store(true, Ordering::SeqCst); - }) - .expect("spawn test supervisor"); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("the validation phase blocks deterministically"); - - assert_bounded_supervisor_shutdown(supervisor, &finished); - assert_eq!( - publications.load(Ordering::SeqCst), - 0, - "cancelled validation cannot publish into the snapshot" - ); - } - - #[test] - fn exit_joins_a_supervisor_blocked_in_health_wait_before_resolve() { - let run = tempfile::TempDir::new().expect("tempdir"); - live_file(54_375, "stable-key") - .write_to(run.path()) - .expect("write candidate"); - let run_dir = run.path().to_owned(); - let (entered, blocked) = mpsc::channel(); - let resolves = Arc::new(AtomicUsize::new(0)); - let worker_resolves = Arc::clone(&resolves); - let finished = Arc::new(AtomicBool::new(false)); - let worker_finished = Arc::clone(&finished); - let supervisor = GatewaySupervisor::spawn(move |cancellation| { - let result = wait_for_launched_file_cancellable_with( - &run_dir, - Duration::from_secs(30), - &cancellation, - |_, _, cancellation| { - entered.send(()).expect("announce blocked health wait"); - let _ = cancellation.wait_timeout(Duration::from_secs(30)); - Err(shared_sidecar::HealthError::Cancelled) - }, - |_, _| { - worker_resolves.fetch_add(1, Ordering::SeqCst); - Ok(Resolution::Absent) - }, - ); - assert!(result.is_err(), "the cancelled health wait is rejected"); - worker_finished.store(true, Ordering::SeqCst); - }) - .expect("spawn test supervisor"); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("the health-wait phase blocks deterministically"); - - assert_bounded_supervisor_shutdown(supervisor, &finished); - assert_eq!( - resolves.load(Ordering::SeqCst), - 0, - "cancelled health waiting cannot start a later resolve" - ); - } - - #[test] - fn exit_joins_a_supervisor_blocked_in_launch_race_before_spawn() { - let run = tempfile::TempDir::new().expect("tempdir"); - let decision = shared_sidecar::launch_or_attach(run.path(), Duration::from_secs(1)) - .expect("acquire a launch decision"); - let run_dir = run.path().to_owned(); - let (entered, blocked) = mpsc::channel(); - let launches = Arc::new(AtomicUsize::new(0)); - let worker_launches = Arc::clone(&launches); - let finished = Arc::new(AtomicBool::new(false)); - let worker_finished = Arc::clone(&finished); - let supervisor = GatewaySupervisor::spawn(move |cancellation| { - let mut decision = Some(decision); - let result = launch_and_attach_cancellable_with( - &run_dir, - Path::new("unused-gateway"), - &cancellation, - |_, _, cancellation| { - entered.send(()).expect("announce blocked launch race"); - let _ = cancellation.wait_timeout(Duration::from_secs(30)); - Ok(decision.take().expect("one launch decision")) - }, - |_, _| { - worker_launches.fetch_add(1, Ordering::SeqCst); - Ok(()) - }, - |_, _, _| anyhow::bail!("the cancelled launch cannot wait for health"), - ); - assert!(result.is_err(), "the cancelled launch race is rejected"); - worker_finished.store(true, Ordering::SeqCst); - }) - .expect("spawn test supervisor"); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("the launch race blocks deterministically"); - - assert_bounded_supervisor_shutdown(supervisor, &finished); - assert_eq!( - launches.load(Ordering::SeqCst), - 0, - "cancelled launch racing cannot create a process" - ); - } - - #[test] - fn exit_joins_a_supervisor_blocked_inside_launch_without_process_creation() { - let run = tempfile::TempDir::new().expect("tempdir"); - let decision = shared_sidecar::launch_or_attach(run.path(), Duration::from_secs(1)) - .expect("acquire a launch decision"); - let run_dir = run.path().to_owned(); - let (entered, blocked) = mpsc::channel(); - let launches = Arc::new(AtomicUsize::new(0)); - let worker_launches = Arc::clone(&launches); - let finished = Arc::new(AtomicBool::new(false)); - let worker_finished = Arc::clone(&finished); - let supervisor = GatewaySupervisor::spawn(move |cancellation| { - let mut decision = Some(decision); - let result = launch_and_attach_cancellable_with( - &run_dir, - Path::new("unused-gateway"), - &cancellation, - |_, _, _| Ok(decision.take().expect("one launch decision")), - |_, cancellation| { - entered.send(()).expect("announce blocked launch"); - if cancellation.wait_timeout(Duration::from_secs(30)) { - return Err(std::io::Error::from(std::io::ErrorKind::Interrupted)); - } - worker_launches.fetch_add(1, Ordering::SeqCst); - Ok(()) - }, - |_, _, _| anyhow::bail!("the cancelled launch cannot wait for health"), - ); - assert!(result.is_err(), "the cancelled launch is rejected"); - worker_finished.store(true, Ordering::SeqCst); - }) - .expect("spawn test supervisor"); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("the process launch blocks deterministically inside its effect gate"); - - assert_bounded_supervisor_shutdown(supervisor, &finished); - assert_eq!( - launches.load(Ordering::SeqCst), - 0, - "the cancelled launch has no post-cancel effect" - ); - } - - #[test] - fn exit_joins_a_supervisor_blocked_inside_publication_without_replacement() { - let (entered, blocked) = mpsc::channel(); - let publications = Arc::new(AtomicUsize::new(0)); - let worker_publications = Arc::clone(&publications); - let finished = Arc::new(AtomicBool::new(false)); - let worker_finished = Arc::clone(&finished); - let supervisor = GatewaySupervisor::spawn(move |cancellation| { - let result = validate_and_publish_with( - &live_file(54_375, "stable-key"), - &cancellation, - |_, _| Ok(()), - |(), cancellation| { - run_effect_if_active(cancellation, "gateway publication", |cancellation| { - entered.send(()).expect("announce blocked publication"); - if cancellation.wait_timeout(Duration::from_secs(30)) { - anyhow::bail!("gateway publication was cancelled"); - } - worker_publications.fetch_add(1, Ordering::SeqCst); - Ok(()) - }) - }, - ); - assert!(result.is_err(), "the cancelled publication is rejected"); - worker_finished.store(true, Ordering::SeqCst); - }) - .expect("spawn test supervisor"); - blocked - .recv_timeout(Duration::from_secs(1)) - .expect("snapshot publication blocks deterministically inside its effect gate"); - - assert_bounded_supervisor_shutdown(supervisor, &finished); - assert_eq!( - publications.load(Ordering::SeqCst), - 0, - "cancellation prevents authoritative snapshot replacement" - ); - } -} +mod tests; diff --git a/crates/workshop/src/gateway/boot.rs b/crates/workshop/src/gateway/boot.rs new file mode 100644 index 00000000..15714b84 --- /dev/null +++ b/crates/workshop/src/gateway/boot.rs @@ -0,0 +1,193 @@ +//! Boot planning and one-shot detached Gateway launch. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use anyhow::Context as _; +use shared_sidecar::{ + ConnectionFile, LaunchDecision, Resolution, SidecarError, ValidatedConnection, +}; +use workshop_server::Config; + +use super::identity::GatewayAttachment; + +/// The sibling executable the shell launches, beside its own. +#[cfg(windows)] +pub(super) const GATEWAY_EXE_NAME: &str = "promptforge-gateway.exe"; +/// The sibling executable the shell launches, beside its own. +#[cfg(not(windows))] +pub(super) const GATEWAY_EXE_NAME: &str = "promptforge-gateway"; + +/// Budget for the launch race and the launched Gateway readiness wait. +const LAUNCH_TIMEOUT: Duration = Duration::from_secs(30); + +/// Delay between polls for the launched Gateway connection file. +const POLL_INTERVAL: Duration = Duration::from_millis(25); + +/// What the boot decision concluded. +#[derive(Debug, PartialEq, Eq)] +pub(super) enum GatewayPlan { + /// A live connection file exists. + Attach(ConnectionFile), + /// No live file exists and a sibling Gateway can be launched. + Launch(PathBuf), + /// Explicit configuration is the only available endpoint. + ConfigOnly, + /// No attachment path exists. + Fail, +} + +/// Connects the Gateway for boot. +/// +/// # Errors +/// Returns an error when no attachment path exists or launch fails. +pub(crate) fn ensure_gateway(config: &Config) -> anyhow::Result { + let exe_dir = std::env::current_exe() + .context("locate the executable") + .and_then(|exe| { + exe.parent() + .map(Path::to_path_buf) + .context("the executable has no parent directory") + })?; + let explicit = !config.gateway.base_url.is_empty(); + let Some(run_dir) = shared_sidecar::default_run_dir() else { + return if explicit { + Ok(GatewayAttachment::Config) + } else { + Err(no_gateway_error()) + }; + }; + match plan_gateway(&run_dir, &exe_dir, explicit, shared_sidecar::resolve) { + GatewayPlan::Attach(file) => validated_attachment(file), + GatewayPlan::ConfigOnly => Ok(GatewayAttachment::Config), + GatewayPlan::Fail => Err(no_gateway_error()), + GatewayPlan::Launch(exe) => launch_and_attach(&run_dir, &exe) + .and_then(validated_attachment) + .context("launch the sidecar gateway"), + } +} + +/// Retains the selected process proof instead of reducing it to file fields. +fn validated_attachment(file: ConnectionFile) -> anyhow::Result { + ValidatedConnection::validate(file) + .map(GatewayAttachment::Sidecar) + .context("validate the selected gateway process identity") +} + +/// Chooses attach, launch, configured fallback, or failure. +pub(super) fn plan_gateway( + run_dir: &Path, + exe_dir: &Path, + explicit_config: bool, + resolve: fn(&Path) -> Result, +) -> GatewayPlan { + match resolve(run_dir) { + Ok(Resolution::Attach(file)) => return GatewayPlan::Attach(file), + Ok(_) => {} + Err(error) => { + eprintln!("could not resolve the gateway connection file: {error}"); + } + } + match sibling_gateway(exe_dir) { + Some(exe) => GatewayPlan::Launch(exe), + None if explicit_config => GatewayPlan::ConfigOnly, + None => GatewayPlan::Fail, + } +} + +/// Locates the installed sibling Gateway executable. +pub(super) fn sibling_gateway(exe_dir: &Path) -> Option { + let candidate = exe_dir.join(GATEWAY_EXE_NAME); + candidate.is_file().then_some(candidate) +} + +/// Builds the loud boot failure naming both supported remedies. +pub(super) fn no_gateway_error() -> anyhow::Error { + anyhow::anyhow!( + "no gateway configured or running; install the Gateway component so \ + promptforge-gateway sits beside the workshop executable, or set \ + gateway.base_url and gateway.api_key in workshop.toml to attach to \ + a gateway over the network" + ) +} + +/// Settles the launch race, launches once when elected, and attaches. +fn launch_and_attach(run_dir: &Path, exe: &Path) -> anyhow::Result { + match shared_sidecar::launch_or_attach(run_dir, LAUNCH_TIMEOUT) + .context("settle the gateway launch race")? + { + LaunchDecision::Attach(file) => Ok(file), + LaunchDecision::Launch(lock) => { + spawn_detached(exe).with_context(|| format!("spawn {}", exe.display()))?; + let file = wait_for_launched_file(run_dir, LAUNCH_TIMEOUT)?; + drop(lock); + Ok(file) + } + decision => anyhow::bail!("an unknown launch decision: {decision:?}"), + } +} + +/// Waits for the launched Gateway to publish a validated connection. +fn wait_for_launched_file(run_dir: &Path, timeout: Duration) -> anyhow::Result { + wait_for_launched_file_with(run_dir, timeout, shared_sidecar::resolve) +} + +/// Waits for readiness with resolution injected for deterministic tests. +pub(super) 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; + loop { + if let Ok(Some(file)) = ConnectionFile::read(run_dir) { + 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 validated connection file within {timeout:?}" + ); + } + std::thread::sleep(POLL_INTERVAL); + } +} + +/// Spawns the Gateway detached from the shell lifetime. +pub(super) fn spawn_detached(exe: &Path) -> std::io::Result<()> { + let mut command = std::process::Command::new(exe); + command + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + const DETACHED_PROCESS: u32 = 0x0000_0008; + command.creation_flags( + CREATE_BREAKAWAY_FROM_JOB | CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS, + ); + } + #[cfg(unix)] + { + use std::os::unix::process::CommandExt as _; + command.process_group(0); + } + let mut child = command.spawn()?; + if let Err(error) = std::thread::Builder::new().spawn(move || { + let _ = child.wait(); + }) { + eprintln!("could not spawn the gateway reaper thread; the child goes unreaped: {error}"); + } + Ok(()) +} diff --git a/crates/workshop/src/gateway/identity.rs b/crates/workshop/src/gateway/identity.rs new file mode 100644 index 00000000..d4912f0f --- /dev/null +++ b/crates/workshop/src/gateway/identity.rs @@ -0,0 +1,30 @@ +//! Local Gateway attachment and validated process identity. + +use shared_sidecar::ValidatedConnection; + +/// How boot connected the Gateway. +#[derive(Debug)] +pub(crate) enum GatewayAttachment { + /// A local sidecar Gateway the shell attached to or launched. + Sidecar(ValidatedConnection), + /// An explicit-config Gateway that the shell does not own. + Config, +} + +impl GatewayAttachment { + /// Returns the validated sidecar identity, when the Gateway is local. + pub(crate) fn sidecar_identity(&self) -> Option<&ValidatedConnection> { + match self { + Self::Sidecar(identity) => Some(identity), + Self::Config => None, + } + } +} + +/// Whether two capabilities prove the same Gateway process boot. +pub(super) fn same_gateway_identity( + left: &ValidatedConnection, + right: &ValidatedConnection, +) -> bool { + left.same_boot(right) +} diff --git a/crates/workshop/src/gateway/supervisor.rs b/crates/workshop/src/gateway/supervisor.rs new file mode 100644 index 00000000..cebccd66 --- /dev/null +++ b/crates/workshop/src/gateway/supervisor.rs @@ -0,0 +1,372 @@ +//! Continuous local Gateway supervision and recovery. + +use std::path::Path; +use std::time::{Duration, Instant}; + +use anyhow::Context as _; +use shared_sidecar::{ + CancellationToken, ConnectionFile, LaunchDecision, Resolution, SidecarError, + ValidatedConnection, +}; + +use super::boot; +use super::identity::{GatewayAttachment, same_gateway_identity}; + +/// 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. +pub(super) const SUPERVISION_MAX_DELAY: Duration = Duration::from_secs(30); + +/// Maximum designed supervisor shutdown latency. +const SUPERVISOR_SHUTDOWN_BUDGET: Duration = Duration::from_secs(3); + +/// Budget for recovery launch-race and readiness phases. +const RECOVERY_TIMEOUT: Duration = Duration::from_secs(30); + +/// Delay between recovery readiness polls. +const RECOVERY_POLL_INTERVAL: Duration = Duration::from_millis(25); + +/// One sidecar liveness observation. +pub(super) enum SupervisionProbe { + /// Another process already published a live replacement. + Replacement(Identity), + /// No live local Gateway is currently discoverable. + Missing, +} + +/// A validated identity retained across supervision classifications. +pub(super) trait SupervisedGatewayIdentity { + /// Whether both values prove the same process boot. + fn same_boot(&self, other: &Self) -> bool; +} + +impl SupervisedGatewayIdentity for ValidatedConnection { + fn same_boot(&self, other: &Self) -> bool { + same_gateway_identity(self, other) + } +} + +/// The running local-sidecar supervisor. +#[derive(Debug)] +pub(crate) struct GatewaySupervisor { + cancellation: CancellationToken, + thread: Option>, +} + +impl GatewaySupervisor { + /// Spawns one owned supervisor thread. + pub(super) fn spawn( + supervise: impl FnOnce(CancellationToken) + Send + 'static, + ) -> anyhow::Result { + let cancellation = CancellationToken::new(); + let worker_cancellation = cancellation.clone(); + let thread = std::thread::Builder::new() + .name("gateway-supervisor".to_owned()) + .spawn(move || supervise(worker_cancellation)) + .context("spawn the gateway supervisor")?; + Ok(Self { + cancellation, + thread: Some(thread), + }) + } + + /// Cancels supervision and joins its thread. + pub(crate) fn shutdown(mut self) { + self.cancel_and_join(); + } + + fn cancel_and_join(&mut self) { + let started = Instant::now(); + self.cancellation.cancel(); + if let Some(thread) = self.thread.take() + && thread.join().is_err() + { + eprintln!("the gateway supervisor panicked during shutdown"); + } + let elapsed = started.elapsed(); + if elapsed > SUPERVISOR_SHUTDOWN_BUDGET { + eprintln!( + "the gateway supervisor exceeded its {SUPERVISOR_SHUTDOWN_BUDGET:?} shutdown budget: {elapsed:?}" + ); + } + } +} + +impl Drop for GatewaySupervisor { + fn drop(&mut self) { + self.cancel_and_join(); + } +} + +/// Starts runtime supervision only for a connection-file sidecar. +/// +/// # Errors +/// Returns an error when the supervisor cannot locate its runtime paths or +/// spawn its owned thread. +pub(crate) fn supervise( + attachment: &GatewayAttachment, + updater: workshop_server::GatewayUpdater, +) -> anyhow::Result> { + let Some(initial) = attachment.sidecar_identity().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 = boot::sibling_gateway(&exe_dir); + GatewaySupervisor::spawn(move |cancellation| { + run_supervision( + initial, + |_, cancellation| match shared_sidecar::resolve_cancellable(&run_dir, cancellation) { + Ok(Resolution::Attach(file)) => { + match ValidatedConnection::validate_cancellable(file, cancellation) { + Ok(identity) => SupervisionProbe::Replacement(identity), + Err(error) => { + eprintln!("could not retain the replacement gateway identity: {error}"); + SupervisionProbe::Missing + } + } + } + Ok(_) | Err(SidecarError::Cancelled) => SupervisionProbe::Missing, + Err(error) => { + eprintln!("could not re-resolve the local gateway: {error}"); + SupervisionProbe::Missing + } + }, + |cancellation| { + let exe = sibling.as_deref().context( + "the local gateway disappeared and no sibling gateway executable is installed", + )?; + let file = launch_and_attach_cancellable(&run_dir, exe, cancellation)?; + ValidatedConnection::validate_cancellable(file, cancellation) + .context("retain the recovered gateway identity") + }, + |validated, cancellation| { + if updater + .replace_sidecar_cancellable(validated, cancellation) + .context("publish the replacement gateway endpoint")? + { + Ok(()) + } else { + anyhow::bail!("gateway publication was cancelled") + } + }, + |delay, cancellation| cancellation.wait_timeout(delay), + &cancellation, + ); + }) + .map(Some) +} + +/// Runs the supervision state machine with I/O injected for tests. +pub(super) fn run_supervision( + mut current: Identity, + mut probe: Probe, + mut recover: Recover, + mut publish: Publish, + mut wait: Wait, + cancellation: &CancellationToken, +) where + Identity: SupervisedGatewayIdentity, + Probe: FnMut(&Identity, &CancellationToken) -> SupervisionProbe, + Recover: FnMut(&CancellationToken) -> Result, + Publish: FnMut(&Identity, &CancellationToken) -> Result<(), Error>, + Wait: FnMut(Duration, &CancellationToken) -> bool, + Error: std::fmt::Display, +{ + let mut retry_delay = SUPERVISION_BASE_DELAY; + loop { + if cancellation.is_cancelled() { + return; + } + let observation = probe(¤t, cancellation); + if cancellation.is_cancelled() { + return; + } + match observation { + SupervisionProbe::Replacement(identity) if identity.same_boot(¤t) => { + retry_delay = SUPERVISION_BASE_DELAY; + if wait(SUPERVISION_INTERVAL, cancellation) { + return; + } + continue; + } + SupervisionProbe::Replacement(identity) => match publish(&identity, cancellation) { + Ok(()) => { + if cancellation.is_cancelled() { + return; + } + current = identity; + retry_delay = SUPERVISION_BASE_DELAY; + continue; + } + Err(error) => { + eprintln!("could not publish a replacement local gateway: {error}"); + } + }, + SupervisionProbe::Missing => match recover(cancellation) { + Ok(_) if cancellation.is_cancelled() => return, + Ok(identity) => match publish(&identity, cancellation) { + Ok(()) => { + if cancellation.is_cancelled() { + return; + } + current = identity; + 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 cancellation.is_cancelled() || wait(retry_delay, cancellation) { + return; + } + retry_delay = retry_delay.saturating_mul(2).min(SUPERVISION_MAX_DELAY); + } +} + +/// Settles and performs a cancellable recovery launch. +fn launch_and_attach_cancellable( + run_dir: &Path, + exe: &Path, + cancellation: &CancellationToken, +) -> anyhow::Result { + launch_and_attach_cancellable_with( + run_dir, + exe, + cancellation, + shared_sidecar::launch_or_attach_cancellable, + |exe, _| boot::spawn_detached(exe), + wait_for_launched_file_cancellable, + ) +} + +/// Recovery launch with each blocking phase injected. +pub(super) fn launch_and_attach_cancellable_with( + run_dir: &Path, + exe: &Path, + cancellation: &CancellationToken, + settle: Settle, + spawn: Spawn, + wait: Wait, +) -> anyhow::Result +where + Settle: FnOnce(&Path, Duration, &CancellationToken) -> Result, + Spawn: FnOnce(&Path, &CancellationToken) -> std::io::Result<()>, + Wait: FnOnce(&Path, Duration, &CancellationToken) -> anyhow::Result, +{ + match settle(run_dir, RECOVERY_TIMEOUT, cancellation) + .context("settle the gateway launch race")? + { + LaunchDecision::Attach(file) => { + if cancellation.is_cancelled() { + anyhow::bail!("gateway attachment was cancelled"); + } + Ok(file) + } + LaunchDecision::Launch(lock) => { + run_effect_if_active(cancellation, "gateway launch", |cancellation| { + if cancellation.is_cancelled() { + anyhow::bail!("gateway launch was cancelled"); + } + spawn(exe, cancellation).with_context(|| format!("spawn {}", exe.display())) + })?; + let file = wait(run_dir, RECOVERY_TIMEOUT, cancellation)?; + drop(lock); + Ok(file) + } + decision => anyhow::bail!("an unknown launch decision: {decision:?}"), + } +} + +/// Linearizes one externally visible recovery effect with cancellation. +pub(super) fn run_effect_if_active( + cancellation: &CancellationToken, + phase: &'static str, + operation: impl FnOnce(&CancellationToken) -> anyhow::Result, +) -> anyhow::Result { + match cancellation.run_if_active(|| operation(cancellation)) { + Some(result) => result, + None => anyhow::bail!("{phase} was cancelled"), + } +} + +/// Waits for a launched recovery Gateway with production probes. +fn wait_for_launched_file_cancellable( + run_dir: &Path, + timeout: Duration, + cancellation: &CancellationToken, +) -> anyhow::Result { + wait_for_launched_file_cancellable_with( + run_dir, + timeout, + cancellation, + shared_sidecar::wait_for_health_cancellable, + shared_sidecar::resolve_cancellable, + ) +} + +/// Recovery readiness wait with health and validation injected. +pub(super) fn wait_for_launched_file_cancellable_with( + run_dir: &Path, + timeout: Duration, + cancellation: &CancellationToken, + mut health: Health, + mut resolve: Resolve, +) -> anyhow::Result +where + Health: FnMut(&str, Duration, &CancellationToken) -> Result<(), shared_sidecar::HealthError>, + Resolve: FnMut(&Path, &CancellationToken) -> Result, +{ + let deadline = Instant::now() + timeout; + loop { + if cancellation.is_cancelled() { + anyhow::bail!("the launched gateway wait was cancelled"); + } + if let Ok(Some(file)) = ConnectionFile::read(run_dir) { + let remaining = deadline.saturating_duration_since(Instant::now()); + let url = format!("http://127.0.0.1:{}", file.port); + health(&url, remaining, cancellation) + .context("the launched gateway did not answer its health probe")?; + if cancellation.is_cancelled() { + anyhow::bail!("the launched gateway wait was cancelled"); + } + match resolve(run_dir, cancellation) { + Ok(Resolution::Attach(validated)) => { + if cancellation.is_cancelled() { + anyhow::bail!("the launched gateway wait was cancelled"); + } + return Ok(validated); + } + Err(SidecarError::Cancelled) => { + anyhow::bail!("the launched gateway wait was cancelled"); + } + Ok(_) | Err(_) => {} + } + } + if cancellation.is_cancelled() { + anyhow::bail!("the launched gateway wait was cancelled"); + } + if Instant::now() >= deadline { + anyhow::bail!( + "the launched gateway wrote no validated connection file within {timeout:?}" + ); + } + if cancellation.wait_timeout(RECOVERY_POLL_INTERVAL) { + anyhow::bail!("the launched gateway wait was cancelled"); + } + } +} diff --git a/crates/workshop/src/gateway/tests.rs b/crates/workshop/src/gateway/tests.rs new file mode 100644 index 00000000..40aba9c5 --- /dev/null +++ b/crates/workshop/src/gateway/tests.rs @@ -0,0 +1,149 @@ +//! Shared fixtures for the split Gateway lifecycle tests. + +use std::io::{Read, Write as _}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use shared_sidecar::{ConnectionFile, Resolution, SidecarError}; + +use super::boot as gateway_boot; + +mod boot; +mod identity; +mod recovery; + +/// Resolves against the test process's own image. +fn probe_own_image(run_dir: &Path) -> Result { + let image = std::env::current_exe() + .expect("current exe") + .file_name() + .expect("the exe has a file name") + .to_string_lossy() + .into_owned(); + shared_sidecar::resolve_for_test(run_dir, &image) +} + +/// Plants an unreadable connection-file path before resolving. +fn probe_read_failure(run_dir: &Path) -> Result { + std::fs::create_dir(shared_sidecar::connection_file_path(run_dir)) + .expect("plant the unreadable file"); + probe_own_image(run_dir) +} + +/// A connection file pointing at the test process itself. +fn live_file(port: u16, api_key: &str) -> ConnectionFile { + ConnectionFile { + port, + api_key: api_key.to_owned(), + pid: std::process::id(), + epoch: 1_757_000_000, + version: "0.2.0".to_owned(), + started_at: "2026-09-03T12:00:00Z".to_owned(), + } +} + +/// Returns a pid whose short-lived child has been reaped. +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 +} + +/// Starts a lightweight same-process health and bearer fixture. +fn fixture_gateway(expected_key: impl Into) -> u16 { + let expected_key = Arc::new(expected_key.into()); + 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() { + let expected_key = Arc::clone(&expected_key); + std::thread::spawn(move || { + loop { + let mut buffer = [0_u8; 1024]; + let Ok(read) = stream.read(&mut buffer) else { + break; + }; + if read == 0 { + break; + } + let request = String::from_utf8_lossy(&buffer[..read]); + let accepted = request.starts_with("GET /health ") + || accepts_bearer(&request, &expected_key); + 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 +} + +fn accepts_bearer(request: &str, expected_key: &str) -> bool { + request.lines().any(|line| { + let Some((name, value)) = line.split_once(':') else { + return false; + }; + let Some((scheme, credential)) = value.trim_start().split_once(' ') else { + return false; + }; + name.eq_ignore_ascii_case("authorization") + && scheme.eq_ignore_ascii_case("bearer") + && credential == expected_key + }) +} + +/// Starts the shared named-process Gateway fixture in this test binary. +fn validated_gateway(expected_key: &str) -> workshop_server::fixtures::ValidatedGateway { + workshop_server::fixtures::ValidatedGateway::spawn_in( + expected_key, + "gateway::tests::validated_gateway_fixture_process", + ) +} + +#[test] +#[ignore = "runs only as a named child process"] +fn validated_gateway_fixture_process() { + workshop_server::fixtures::run_validated_gateway_fixture_process(); +} + +/// Sends one plain GET to the Workshop fixture. +fn get(url: &str, path: &str) -> String { + let url = url::Url::parse(url).expect("the Workshop URL parses"); + let host = url.host_str().expect("the Workshop URL has a host"); + let port = url.port().expect("the Workshop URL has a port"); + let mut stream = TcpStream::connect((host, port)).expect("connect to Workshop"); + write!( + stream, + "GET {path} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\n\r\n" + ) + .expect("send Workshop request"); + let mut response = String::new(); + stream + .read_to_string(&mut response) + .expect("read Workshop response"); + response +} + +/// An executable directory, with or without the sibling Gateway. +fn exe_dir(with_gateway: bool) -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::TempDir::new().expect("tempdir"); + if with_gateway { + std::fs::write(dir.path().join(gateway_boot::GATEWAY_EXE_NAME), b"") + .expect("plant the sibling exe"); + } + let path = dir.path().to_owned(); + (dir, path) +} diff --git a/crates/workshop/src/gateway/tests/boot.rs b/crates/workshop/src/gateway/tests/boot.rs new file mode 100644 index 00000000..d5166fc1 --- /dev/null +++ b/crates/workshop/src/gateway/tests/boot.rs @@ -0,0 +1,191 @@ +//! Boot planning and one-shot launch coverage. + +use std::time::Duration; + +use shared_sidecar::ConnectionFile; + +use super::{dead_pid, exe_dir, fixture_gateway, live_file, probe_own_image, probe_read_failure}; +use crate::gateway::boot::{ + GATEWAY_EXE_NAME, GatewayPlan, no_gateway_error, plan_gateway, sibling_gateway, + wait_for_launched_file_with, +}; + +#[test] +fn a_live_file_attaches_without_looking_for_a_sibling_exe() { + let run = tempfile::TempDir::new().expect("tempdir"); + let file = live_file(fixture_gateway("key"), "key"); + file.write_to(run.path()).expect("write"); + let (_exe, exe_dir) = exe_dir(false); + + match plan_gateway(run.path(), &exe_dir, false, probe_own_image) { + GatewayPlan::Attach(attached) => assert_eq!(attached, file), + other => panic!("a live gateway must be attached, not {other:?}"), + } +} + +#[test] +fn no_file_and_a_sibling_exe_launches() { + let run = tempfile::TempDir::new().expect("tempdir"); + let (_exe, exe_dir) = exe_dir(true); + + match plan_gateway(run.path(), &exe_dir, false, probe_own_image) { + GatewayPlan::Launch(exe) => assert_eq!(exe, exe_dir.join(GATEWAY_EXE_NAME)), + other => panic!("a full install with no running gateway must launch, not {other:?}"), + } +} + +#[test] +fn no_file_and_no_sibling_exe_falls_through_to_explicit_config() { + let run = tempfile::TempDir::new().expect("tempdir"); + let (_exe, exe_dir) = exe_dir(false); + + assert_eq!( + plan_gateway(run.path(), &exe_dir, true, probe_own_image), + GatewayPlan::ConfigOnly, + "a Workshop-only install attaches to the configured LAN gateway" + ); +} + +#[test] +fn no_file_no_sibling_exe_and_no_config_fails() { + let run = tempfile::TempDir::new().expect("tempdir"); + let (_exe, exe_dir) = exe_dir(false); + + assert_eq!( + plan_gateway(run.path(), &exe_dir, false, probe_own_image), + GatewayPlan::Fail, + "nothing to connect to must fail loud, not serve a broken window" + ); +} + +#[test] +fn a_stale_file_is_cleaned_and_the_sibling_exe_launches() { + let run = tempfile::TempDir::new().expect("tempdir"); + ConnectionFile { + pid: dead_pid(), + ..live_file(1, "k") + } + .write_to(run.path()) + .expect("write"); + let (_exe, exe_dir) = exe_dir(true); + + let plan = plan_gateway(run.path(), &exe_dir, false, probe_own_image); + assert!( + matches!(plan, GatewayPlan::Launch(_)), + "a stale file must not block the relaunch: {plan:?}" + ); + assert!( + !shared_sidecar::connection_file_path(run.path()).exists(), + "the stale file was cleaned" + ); +} + +#[test] +fn a_stale_file_with_no_sibling_exe_falls_through_to_explicit_config() { + let run = tempfile::TempDir::new().expect("tempdir"); + ConnectionFile { + pid: dead_pid(), + ..live_file(1, "k") + } + .write_to(run.path()) + .expect("write"); + let (_exe, exe_dir) = exe_dir(false); + + assert_eq!( + plan_gateway(run.path(), &exe_dir, true, probe_own_image), + GatewayPlan::ConfigOnly, + "a stale file must not wedge the LAN fallback" + ); + assert!( + !shared_sidecar::connection_file_path(run.path()).exists(), + "the stale file was cleaned" + ); +} + +#[test] +fn a_resolve_error_still_launches_the_sibling_exe() { + let run = tempfile::TempDir::new().expect("tempdir"); + let (_exe, exe_dir) = exe_dir(true); + + let plan = plan_gateway(run.path(), &exe_dir, false, probe_read_failure); + assert!( + matches!(plan, GatewayPlan::Launch(_)), + "a discovery error must not read as no-gateway: {plan:?}" + ); +} + +#[test] +fn the_sibling_probe_finds_only_the_gateway_exe_beside_the_shell() { + let (_dir, with) = exe_dir(true); + assert_eq!( + sibling_gateway(&with), + Some(with.join(GATEWAY_EXE_NAME)), + "the installed sibling is found" + ); + let (_dir, without) = exe_dir(false); + assert_eq!( + sibling_gateway(&without), + None, + "a Workshop-only install has no sibling" + ); +} + +#[test] +fn the_launch_wait_returns_once_the_file_appears_and_answers() { + let run = tempfile::TempDir::new().expect("tempdir"); + let file = live_file(fixture_gateway("key"), "key"); + let run_path = run.path().to_owned(); + let written = file.clone(); + let writer = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(100)); + written + .write_to(&run_path) + .expect("the launched gateway writes"); + }); + + 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"); +} + +#[test] +fn the_launch_wait_times_out_when_no_file_appears() { + let run = tempfile::TempDir::new().expect("tempdir"); + 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 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"); + live_file(fixture_gateway("accepted-key"), "rejected-key") + .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 the_no_gateway_error_names_both_remedies() { + let message = no_gateway_error().to_string(); + assert!( + message.contains("promptforge-gateway"), + "the error names the Gateway component remedy: {message}" + ); + assert!( + message.contains("workshop.toml"), + "the error names the explicit-config remedy: {message}" + ); +} diff --git a/crates/workshop/src/gateway/tests/identity.rs b/crates/workshop/src/gateway/tests/identity.rs new file mode 100644 index 00000000..4692341b --- /dev/null +++ b/crates/workshop/src/gateway/tests/identity.rs @@ -0,0 +1,37 @@ +//! Attachment and process-boot identity coverage. + +use super::validated_gateway; +use crate::gateway::identity::{GatewayAttachment, same_gateway_identity}; + +#[test] +fn an_explicit_config_attachment_holds_no_local_sidecar_identity() { + let gateway = validated_gateway("k"); + let identity = gateway.validate("k", 1_757_000_000, "2026-09-03T12:00:00Z"); + let sidecar = GatewayAttachment::Sidecar(identity.clone()); + assert!( + sidecar + .sidecar_identity() + .is_some_and(|attached| attached.same_boot(&identity)), + "a sidecar attachment retains its validated process identity" + ); + let config = GatewayAttachment::Config; + assert_eq!( + config.sidecar_identity(), + None, + "a LAN Gateway from explicit config carries no local sidecar identity" + ); +} + +#[test] +fn validated_process_boot_identity_distinguishes_live_gateway_children() { + let first = validated_gateway("stable-key"); + let second = validated_gateway("stable-key"); + let original = first.validate("stable-key", 1_757_000_000, "2026-09-03T12:00:00Z"); + let replacement = second.validate("stable-key", 1_757_000_000, "2026-09-03T12:00:00Z"); + + assert!(same_gateway_identity(&original, &original.clone())); + assert!( + !same_gateway_identity(&original, &replacement), + "equal file-supplied boot metadata cannot alias another validated process" + ); +} diff --git a/crates/workshop/src/gateway/tests/recovery.rs b/crates/workshop/src/gateway/tests/recovery.rs new file mode 100644 index 00000000..e29e5c47 --- /dev/null +++ b/crates/workshop/src/gateway/tests/recovery.rs @@ -0,0 +1,464 @@ +//! Continuous supervision, recovery, and joined-shutdown coverage. + +use std::cell::{Cell, RefCell}; +use std::path::Path; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, mpsc}; +use std::time::{Duration, Instant}; + +use anyhow::Context as _; +use shared_sidecar::{CancellationToken, ConnectionFile, Resolution, ValidatedConnection}; + +use super::{get, live_file, validated_gateway}; +use crate::gateway::supervisor::{ + GatewaySupervisor, SUPERVISION_MAX_DELAY, SupervisedGatewayIdentity, SupervisionProbe, + launch_and_attach_cancellable_with, run_effect_if_active, run_supervision, + wait_for_launched_file_cancellable_with, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct TestIdentity { + file: ConnectionFile, + process_boot: u64, +} + +impl SupervisedGatewayIdentity for TestIdentity { + fn same_boot(&self, other: &Self) -> bool { + self.process_boot == other.process_boot + && self.file.pid == other.file.pid + && self.file.epoch == other.file.epoch + && self.file.started_at == other.file.started_at + } +} + +fn test_identity(file: ConnectionFile) -> TestIdentity { + TestIdentity { + process_boot: u64::from(file.pid), + file, + } +} + +#[test] +fn supervision_lives_past_sixty_seconds_then_propagates_a_configured_key_edit_atomically() { + let gateway = validated_gateway("new-key"); + let original = gateway.connection_file("old-key", 1_757_000_000, "2026-09-03T12:00:00Z"); + let replacement = gateway.connection_file("new-key", 1_757_000_001, "2026-09-03T12:00:01Z"); + let state_dir = tempfile::TempDir::new().expect("create Workshop state directory"); + let server = workshop_server::fixtures::spawn(workshop_server::Config { + gateway: workshop_server::GatewayConfig { + base_url: format!("http://127.0.0.1:{}", original.port), + api_key: original.api_key.clone(), + }, + server: workshop_server::ServerConfig { + bind: "127.0.0.1:0".to_owned(), + open_browser: false, + state_dir: state_dir.path().to_owned(), + }, + agents: workshop_server::AgentsConfig::default(), + }) + .expect("spawn Workshop against the original same-port key"); + let updater = server.gateway_updater(); + let publish_replacement = |file: &ConnectionFile| -> anyhow::Result<()> { + let validated = ValidatedConnection::validate(file.clone()) + .context("validate the named local Gateway")?; + updater + .replace_sidecar(&validated) + .context("publish through the production updater") + }; + let elapsed = Cell::new(Duration::ZERO); + let recoveries = Cell::new(0_u8); + let published = RefCell::new(Vec::new()); + let cancellation = CancellationToken::new(); + + run_supervision( + test_identity(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(test_identity(replacement.clone())) + }, + |identity, _| { + publish_replacement(&identity.file)?; + published.borrow_mut().push(identity.file.clone()); + Ok::<(), anyhow::Error>(()) + }, + |delay, _| { + assert!( + delay <= SUPERVISION_MAX_DELAY, + "every supervision wait is capped: {delay:?}" + ); + elapsed.set(elapsed.get() + delay); + !published.borrow().is_empty() + }, + &cancellation, + ); + + 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" + ); + let response = get(server.url(), "/gateway/api/admin/status"); + assert!( + response.starts_with("HTTP/1.1 200"), + "the real publisher replaces the bearer on the reused port: {response}" + ); + server.shutdown().expect("stop the Workshop fixture"); +} + +#[test] +fn reused_pid_and_file_metadata_still_publish_a_new_validated_process_boot() { + let file = live_file(54_375, "stable-key"); + let original = TestIdentity { + file: file.clone(), + process_boot: 41, + }; + let replacement = TestIdentity { + file: file.clone(), + process_boot: 42, + }; + let published = RefCell::new(Vec::new()); + let cancellation = CancellationToken::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") + }, + |identity, _| { + published.borrow_mut().push(identity.clone()); + Ok(()) + }, + |_, _| !published.borrow().is_empty(), + &cancellation, + ); + + assert_eq!( + published.borrow().as_slice(), + [replacement], + "the stable OS boot token prevents identical file fields from hiding pid reuse" + ); + assert_eq!(published.borrow()[0].file, original.file); +} + +fn assert_bounded_supervisor_shutdown(supervisor: GatewaySupervisor, finished: &AtomicBool) { + let started = Instant::now(); + supervisor.shutdown(); + assert!( + started.elapsed() < Duration::from_millis(250), + "Workshop exit joins the cancelled supervisor within its budget" + ); + assert!( + finished.load(Ordering::SeqCst), + "shutdown returns only after the supervisor thread exits" + ); +} + +#[test] +fn exit_joins_a_supervisor_blocked_in_resolve_before_recovery() { + let (entered, blocked) = mpsc::channel(); + let recoveries = Arc::new(AtomicUsize::new(0)); + let worker_recoveries = Arc::clone(&recoveries); + let finished = Arc::new(AtomicBool::new(false)); + let worker_finished = Arc::clone(&finished); + let supervisor = GatewaySupervisor::spawn(move |cancellation| { + run_supervision( + test_identity(live_file(54_375, "stable-key")), + |_, cancellation| { + entered.send(()).expect("announce blocked resolve"); + let _ = cancellation.wait_timeout(Duration::from_secs(30)); + SupervisionProbe::Missing + }, + |_| -> anyhow::Result { + worker_recoveries.fetch_add(1, Ordering::SeqCst); + anyhow::bail!("recovery must not start after cancellation") + }, + |_, _| Ok::<(), anyhow::Error>(()), + |delay, cancellation| cancellation.wait_timeout(delay), + &cancellation, + ); + worker_finished.store(true, Ordering::SeqCst); + }) + .expect("spawn test supervisor"); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("the resolve phase blocks deterministically"); + + assert_bounded_supervisor_shutdown(supervisor, &finished); + assert_eq!( + recoveries.load(Ordering::SeqCst), + 0, + "cancellation prevents every later recovery launch" + ); +} + +#[test] +fn exit_wakes_the_supervision_wait_without_a_later_probe() { + let (entered, blocked) = mpsc::channel(); + let probes = Arc::new(AtomicUsize::new(0)); + let worker_probes = Arc::clone(&probes); + let finished = Arc::new(AtomicBool::new(false)); + let worker_finished = Arc::clone(&finished); + let supervisor = GatewaySupervisor::spawn(move |cancellation| { + run_supervision( + test_identity(live_file(54_375, "stable-key")), + |current, _| { + worker_probes.fetch_add(1, Ordering::SeqCst); + SupervisionProbe::Replacement(current.clone()) + }, + |_| -> anyhow::Result { panic!("a healthy Gateway does not recover") }, + |_, _| Ok::<(), anyhow::Error>(()), + |delay, cancellation| { + entered.send(()).expect("announce supervision wait"); + cancellation.wait_timeout(delay) + }, + &cancellation, + ); + worker_finished.store(true, Ordering::SeqCst); + }) + .expect("spawn test supervisor"); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("the supervision wait blocks deterministically"); + + assert_bounded_supervisor_shutdown(supervisor, &finished); + assert_eq!( + probes.load(Ordering::SeqCst), + 1, + "cancellation prevents every later liveness probe" + ); +} + +#[test] +fn exit_joins_a_supervisor_blocked_in_validation_before_publication() { + let (entered, blocked) = mpsc::channel(); + let publications = Arc::new(AtomicUsize::new(0)); + let worker_publications = Arc::clone(&publications); + let finished = Arc::new(AtomicBool::new(false)); + let worker_finished = Arc::clone(&finished); + let supervisor = GatewaySupervisor::spawn(move |cancellation| { + run_supervision( + test_identity(live_file(54_375, "stable-key")), + |current, cancellation| { + entered.send(()).expect("announce blocked validation"); + let _ = cancellation.wait_timeout(Duration::from_secs(30)); + SupervisionProbe::Replacement(current.clone()) + }, + |_| -> anyhow::Result { + panic!("cancelled validation cannot start recovery") + }, + |_, _| { + worker_publications.fetch_add(1, Ordering::SeqCst); + Ok::<(), anyhow::Error>(()) + }, + |delay, cancellation| cancellation.wait_timeout(delay), + &cancellation, + ); + worker_finished.store(true, Ordering::SeqCst); + }) + .expect("spawn test supervisor"); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("the validation phase blocks deterministically"); + + assert_bounded_supervisor_shutdown(supervisor, &finished); + assert_eq!( + publications.load(Ordering::SeqCst), + 0, + "cancelled validation cannot publish into the snapshot" + ); +} + +#[test] +fn exit_joins_a_supervisor_blocked_in_health_wait_before_resolve() { + let run = tempfile::TempDir::new().expect("tempdir"); + live_file(54_375, "stable-key") + .write_to(run.path()) + .expect("write candidate"); + let run_dir = run.path().to_owned(); + let (entered, blocked) = mpsc::channel(); + let resolves = Arc::new(AtomicUsize::new(0)); + let worker_resolves = Arc::clone(&resolves); + let finished = Arc::new(AtomicBool::new(false)); + let worker_finished = Arc::clone(&finished); + let supervisor = GatewaySupervisor::spawn(move |cancellation| { + let result = wait_for_launched_file_cancellable_with( + &run_dir, + Duration::from_secs(30), + &cancellation, + |_, _, cancellation| { + entered.send(()).expect("announce blocked health wait"); + let _ = cancellation.wait_timeout(Duration::from_secs(30)); + Err(shared_sidecar::HealthError::Cancelled) + }, + |_, _| { + worker_resolves.fetch_add(1, Ordering::SeqCst); + Ok(Resolution::Absent) + }, + ); + assert!(result.is_err(), "the cancelled health wait is rejected"); + worker_finished.store(true, Ordering::SeqCst); + }) + .expect("spawn test supervisor"); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("the health-wait phase blocks deterministically"); + + assert_bounded_supervisor_shutdown(supervisor, &finished); + assert_eq!( + resolves.load(Ordering::SeqCst), + 0, + "cancelled health waiting cannot start a later resolve" + ); +} + +#[test] +fn exit_joins_a_supervisor_blocked_in_launch_race_before_spawn() { + let run = tempfile::TempDir::new().expect("tempdir"); + let decision = shared_sidecar::launch_or_attach(run.path(), Duration::from_secs(1)) + .expect("acquire a launch decision"); + let run_dir = run.path().to_owned(); + let (entered, blocked) = mpsc::channel(); + let launches = Arc::new(AtomicUsize::new(0)); + let worker_launches = Arc::clone(&launches); + let finished = Arc::new(AtomicBool::new(false)); + let worker_finished = Arc::clone(&finished); + let supervisor = GatewaySupervisor::spawn(move |cancellation| { + let mut decision = Some(decision); + let result = launch_and_attach_cancellable_with( + &run_dir, + Path::new("unused-gateway"), + &cancellation, + |_, _, cancellation| { + entered.send(()).expect("announce blocked launch race"); + let _ = cancellation.wait_timeout(Duration::from_secs(30)); + Ok(decision.take().expect("one launch decision")) + }, + |_, _| { + worker_launches.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + |_, _, _| anyhow::bail!("the cancelled launch cannot wait for health"), + ); + assert!(result.is_err(), "the cancelled launch race is rejected"); + worker_finished.store(true, Ordering::SeqCst); + }) + .expect("spawn test supervisor"); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("the launch race blocks deterministically"); + + assert_bounded_supervisor_shutdown(supervisor, &finished); + assert_eq!( + launches.load(Ordering::SeqCst), + 0, + "cancelled launch racing cannot create a process" + ); +} + +#[test] +fn exit_joins_a_supervisor_blocked_inside_launch_without_process_creation() { + let run = tempfile::TempDir::new().expect("tempdir"); + let decision = shared_sidecar::launch_or_attach(run.path(), Duration::from_secs(1)) + .expect("acquire a launch decision"); + let run_dir = run.path().to_owned(); + let (entered, blocked) = mpsc::channel(); + let launches = Arc::new(AtomicUsize::new(0)); + let worker_launches = Arc::clone(&launches); + let finished = Arc::new(AtomicBool::new(false)); + let worker_finished = Arc::clone(&finished); + let supervisor = GatewaySupervisor::spawn(move |cancellation| { + let mut decision = Some(decision); + let result = launch_and_attach_cancellable_with( + &run_dir, + Path::new("unused-gateway"), + &cancellation, + |_, _, _| Ok(decision.take().expect("one launch decision")), + |_, cancellation| { + entered.send(()).expect("announce blocked launch"); + if cancellation.wait_timeout(Duration::from_secs(30)) { + return Err(std::io::Error::from(std::io::ErrorKind::Interrupted)); + } + worker_launches.fetch_add(1, Ordering::SeqCst); + Ok(()) + }, + |_, _, _| anyhow::bail!("the cancelled launch cannot wait for health"), + ); + assert!(result.is_err(), "the cancelled launch is rejected"); + worker_finished.store(true, Ordering::SeqCst); + }) + .expect("spawn test supervisor"); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("the process launch blocks deterministically inside its effect gate"); + + assert_bounded_supervisor_shutdown(supervisor, &finished); + assert_eq!( + launches.load(Ordering::SeqCst), + 0, + "the cancelled launch has no post-cancel effect" + ); +} + +#[test] +fn exit_joins_a_supervisor_blocked_inside_publication_without_replacement() { + let (entered, blocked) = mpsc::channel(); + let publications = Arc::new(AtomicUsize::new(0)); + let worker_publications = Arc::clone(&publications); + let finished = Arc::new(AtomicBool::new(false)); + let worker_finished = Arc::clone(&finished); + let supervisor = GatewaySupervisor::spawn(move |cancellation| { + let result = run_effect_if_active(&cancellation, "gateway publication", |cancellation| { + entered.send(()).expect("announce blocked publication"); + if cancellation.wait_timeout(Duration::from_secs(30)) { + anyhow::bail!("gateway publication was cancelled"); + } + worker_publications.fetch_add(1, Ordering::SeqCst); + Ok(()) + }); + assert!(result.is_err(), "the cancelled publication is rejected"); + worker_finished.store(true, Ordering::SeqCst); + }) + .expect("spawn test supervisor"); + blocked + .recv_timeout(Duration::from_secs(1)) + .expect("snapshot publication blocks deterministically inside its effect gate"); + + assert_bounded_supervisor_shutdown(supervisor, &finished); + assert_eq!( + publications.load(Ordering::SeqCst), + 0, + "cancellation prevents authoritative snapshot replacement" + ); +} diff --git a/crates/workshop/src/main.rs b/crates/workshop/src/main.rs index ae832ee6..a9ec9220 100644 --- a/crates/workshop/src/main.rs +++ b/crates/workshop/src/main.rs @@ -193,7 +193,7 @@ fn boot_and_open(app: &mut tauri::App) -> Result<(), Box> app.add_capability(window_capability(&url))?; app.manage(GatewaySupervisorSlot::new(supervisor)); app.manage(ServerSlot::new(Some(server))); - menu::install(app, attachment.sidecar_file())?; + menu::install(app, attachment.sidecar_identity().is_some())?; open_window(app, &url) } Err(error) => { diff --git a/crates/workshop/src/menu.rs b/crates/workshop/src/menu.rs index db249b28..b5de0402 100644 --- a/crates/workshop/src/menu.rs +++ b/crates/workshop/src/menu.rs @@ -9,7 +9,6 @@ use std::sync::PoisonError; -use shared_sidecar::ConnectionFile; use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder}; use tauri::{AppHandle, Manager as _, Wry}; @@ -18,15 +17,13 @@ use crate::ServerSlot; /// The quit item's menu id, matched by the event handler. pub(crate) const QUIT_MENU_ID: &str = "quit-promptforge"; -/// Builds and installs the app menu. `sidecar` is the attached or -/// launched gateway's connection file: present, the quit item also stops -/// the gateway and its label says so; absent (a LAN gateway from -/// explicit config), the item stops the shell only. +/// Builds and installs the app menu. A local sidecar makes the quit item +/// stop both products; a configured LAN Gateway makes it stop only the shell. /// /// # Errors /// Returns an error when the menu cannot be built or installed. -pub(crate) fn install(app: &tauri::App, sidecar: Option<&ConnectionFile>) -> tauri::Result<()> { - let label = if sidecar.is_some() { +pub(crate) fn install(app: &tauri::App, has_sidecar: bool) -> tauri::Result<()> { + let label = if has_sidecar { "Quit PromptForge and Gateway" } else { "Quit PromptForge" diff --git a/crates/workshop/tests/module_ceiling.rs b/crates/workshop/tests/module_ceiling.rs new file mode 100644 index 00000000..9c646db8 --- /dev/null +++ b/crates/workshop/tests/module_ceiling.rs @@ -0,0 +1,147 @@ +//! Module size ratchet for the Workshop binary and its tests. + +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +/// Fixed growth allowance above each recorded physical-line count. +const SLACK: usize = 30; + +#[derive(serde::Deserialize)] +struct CeilingsFile { + source_modules: BTreeMap, + test_modules: BTreeMap, +} + +fn crate_root() -> &'static Path { + Path::new(env!("CARGO_MANIFEST_DIR")) +} + +#[expect( + clippy::expect_used, + reason = "test helpers fail by panicking with the invariant named" +)] +fn recorded_ceilings() -> CeilingsFile { + let path = crate_root().join("module-ceilings.toml"); + let text = fs::read_to_string(&path).expect("module-ceilings.toml exists at the crate root"); + toml::from_str(&text).expect("module-ceilings.toml parses as TOML") +} + +fn physical_lines(text: &str) -> usize { + text.lines().count() +} + +#[expect( + clippy::expect_used, + reason = "test helpers fail by panicking with the invariant named" +)] +fn collect_rust_modules(root: &Path, directory: &Path, out: &mut BTreeMap) { + let mut entries = fs::read_dir(directory) + .expect("read a Workshop module directory") + .collect::, _>>() + .expect("read every Workshop module entry"); + entries.sort_by_key(std::fs::DirEntry::file_name); + for entry in entries { + let path = entry.path(); + if path.is_dir() { + collect_rust_modules(root, &path, out); + } else if path.extension().is_some_and(|extension| extension == "rs") { + let relative = path + .strip_prefix(root) + .expect("a measured module stays below its root"); + let name = relative.to_string_lossy().replace('\\', "/"); + let text = fs::read_to_string(&path).expect("read a Workshop Rust module"); + out.insert(name, physical_lines(&text)); + } + } +} + +fn measured_modules(directory: &str) -> BTreeMap { + let root = crate_root().join(directory); + let mut modules = BTreeMap::new(); + collect_rust_modules(&root, &root, &mut modules); + modules +} + +fn check_ceiling( + group: &str, + measured: &BTreeMap, + ceilings: &BTreeMap, +) -> Vec { + let mut overgrown = Vec::new(); + for (module, count) in measured { + let Some(&ceiling) = ceilings.get(module) else { + continue; + }; + if *count > ceiling + SLACK { + overgrown.push(format!( + " {group}/{module}: {count} lines, ceiling {ceiling} (slack {SLACK} allows {})", + ceiling + SLACK + )); + } + } + overgrown +} + +fn check_sync( + group: &str, + measured: &BTreeMap, + ceilings: &BTreeMap, +) -> Vec { + let mut drift = Vec::new(); + for (module, count) in measured { + if !ceilings.contains_key(module) { + drift.push(format!( + " missing entry: add `\"{module}\" = {count}` to [{group}_modules]" + )); + } + } + for module in ceilings.keys() { + if !measured.contains_key(module) { + drift.push(format!( + " stale entry: remove `\"{module}\"` from [{group}_modules]" + )); + } + } + drift +} + +#[test] +fn every_workshop_module_stays_at_or_below_its_ceiling_plus_slack() { + let recorded = recorded_ceilings(); + let mut overgrown = check_ceiling("source", &measured_modules("src"), &recorded.source_modules); + overgrown.extend(check_ceiling( + "test", + &measured_modules("tests"), + &recorded.test_modules, + )); + assert!( + overgrown.is_empty(), + "Workshop module size ratchet tripped:\n{}", + overgrown.join("\n") + ); +} + +#[test] +fn the_ceiling_file_lists_every_source_and_test_module_exactly_once() { + let recorded = recorded_ceilings(); + let mut drift = check_sync("source", &measured_modules("src"), &recorded.source_modules); + drift.extend(check_sync( + "test", + &measured_modules("tests"), + &recorded.test_modules, + )); + assert!( + drift.is_empty(), + "module-ceilings.toml is out of step with Workshop modules:\n{}", + drift.join("\n") + ); +} + +#[test] +fn the_counting_rule_handles_crlf_lf_and_unterminated_files() { + assert_eq!(physical_lines(""), 0); + assert_eq!(physical_lines("one line, no trailing newline"), 1); + assert_eq!(physical_lines("one line\n"), 1); + assert_eq!(physical_lines("one\r\ntwo\r\n"), 2); +} diff --git a/guide/promptforge-workshop-guide.md b/guide/promptforge-workshop-guide.md index cdc17022..7dcbfd4d 100644 --- a/guide/promptforge-workshop-guide.md +++ b/guide/promptforge-workshop-guide.md @@ -41,15 +41,16 @@ You can also run the Workshop's server on its own and use the interface in an or The first time you start the Workshop, the application prepares everything it needs before you see a window. Follow what happens: 1. The application looks for its boot configuration. -2. It starts its server inside its own process and waits until the server accepts connections. -3. It waits for the interface to answer a health check, up to 15 seconds. -4. Only then does the window open. +2. It attaches to a running local gateway through its validated connection file. If none is running, it launches the sibling `promptforge-gateway`; a Workshop-only install instead uses the explicit gateway in `workshop.toml`. +3. It starts its server inside its own process and waits until the server accepts connections. +4. It waits for the interface to answer a health check, up to 15 seconds. +5. Only then does the window open. You never see a window before the interface is ready, and the interface never opens against a dead server. If the server does not answer in time, the error message names the health endpoint and how long the application waited. If startup fails for any reason, the application prints the full error chain and exits with a failure code instead of opening a broken window. Only one instance of the Workshop runs at a time. If you launch it again while it is already running, the existing window comes into focus instead of a second copy opening. When you close the window, the application shuts its built-in server down cleanly and exits; the gateway is a separate program and keeps running. To stop the gateway together with the window, use the quit command instead: Quit PromptForge and Gateway on the application menu, or Ctrl+Q (Cmd+Q on macOS). When the Workshop is attached to a gateway on another machine, the command reads Quit PromptForge and stops only the window - a client never stops a shared gateway. In-flight connections get a 5-second grace window, so a held chat session or a stuck request cannot hang the shutdown. The interface listens on an OS-assigned loopback port, so another program holding a port can never block startup. -The Workshop also keeps working when parts of its environment fail. The interface still loads when the gateway is unreachable, so a gateway outage never prevents the application from opening. If microphone setup fails at startup, you keep working and only voice input stays unavailable. On Windows, if the bridge to Explorer fails to attach, the application keeps running and loses only Explorer drag-and-drop and the microphone grant. +The Workshop also keeps working when parts of its environment fail. After boot, if a local gateway exits, the application keeps the interface open while it looks for a validated replacement or relaunches the installed sibling with bounded backoff. A replacement is published only after its process identity, health response, and bearer key all validate, and the server switches its clients and credentials together. Explicitly configured gateways on another machine are never launched, supervised, or stopped by the Workshop. If microphone setup fails at startup, you keep working and only voice input stays unavailable. On Windows, if the bridge to Explorer fails to attach, the application keeps running and loses only Explorer drag-and-drop and the microphone grant. ## The gateway configuration @@ -72,7 +73,7 @@ You configure the Workshop through a TOML file named `workshop.toml`. The applic The keys you are most likely to set: -- `gateway.base_url` points the Workshop at a PromptForge gateway the connection file cannot see, such as one on another machine. When the value is empty, the Workshop attaches to a locally running gateway through its connection file, and with no gateway running, startup fails with an error that names both remedies. +- `gateway.base_url` points the Workshop at a PromptForge gateway the connection file cannot see, such as one on another machine. When the value is empty, the Workshop attaches to a locally running gateway through its connection file or launches the sibling `promptforge-gateway`. A Workshop-only install has no sibling, so with neither a running gateway nor an explicit value, startup fails with an error that names both remedies. - `gateway.api_key` supplies the bearer key for the gateway API. An empty key sends no `Authorization` header, which is right for a gateway running with authentication disabled. - `server.bind` is honored only by the standalone `workshop-server` binary. The desktop application owns its listener and always binds `127.0.0.1` on an OS-assigned port. - `server.state_dir` chooses where the Workshop keeps persistent state. Agent session event logs live under `state_dir/sessions/`, and the per-profile model memory is written there. It defaults to the config file's own directory. diff --git a/guide/src/workshop/01-application.md b/guide/src/workshop/01-application.md index 0aebdba6..2af0efe8 100644 --- a/guide/src/workshop/01-application.md +++ b/guide/src/workshop/01-application.md @@ -37,15 +37,16 @@ You can also run the Workshop's server on its own and use the interface in an or The first time you start the Workshop, the application prepares everything it needs before you see a window. Follow what happens: 1. The application looks for its boot configuration. -2. It starts its server inside its own process and waits until the server accepts connections. -3. It waits for the interface to answer a health check, up to 15 seconds. -4. Only then does the window open. +2. It attaches to a running local gateway through its validated connection file. If none is running, it launches the sibling `promptforge-gateway`; a Workshop-only install instead uses the explicit gateway in `workshop.toml`. +3. It starts its server inside its own process and waits until the server accepts connections. +4. It waits for the interface to answer a health check, up to 15 seconds. +5. Only then does the window open. You never see a window before the interface is ready, and the interface never opens against a dead server. If the server does not answer in time, the error message names the health endpoint and how long the application waited. If startup fails for any reason, the application prints the full error chain and exits with a failure code instead of opening a broken window. Only one instance of the Workshop runs at a time. If you launch it again while it is already running, the existing window comes into focus instead of a second copy opening. When you close the window, the application shuts its built-in server down cleanly and exits; the gateway is a separate program and keeps running. To stop the gateway together with the window, use the quit command instead: Quit PromptForge and Gateway on the application menu, or Ctrl+Q (Cmd+Q on macOS). When the Workshop is attached to a gateway on another machine, the command reads Quit PromptForge and stops only the window - a client never stops a shared gateway. In-flight connections get a 5-second grace window, so a held chat session or a stuck request cannot hang the shutdown. The interface listens on an OS-assigned loopback port, so another program holding a port can never block startup. -The Workshop also keeps working when parts of its environment fail. The interface still loads when the gateway is unreachable, so a gateway outage never prevents the application from opening. If microphone setup fails at startup, you keep working and only voice input stays unavailable. On Windows, if the bridge to Explorer fails to attach, the application keeps running and loses only Explorer drag-and-drop and the microphone grant. +The Workshop also keeps working when parts of its environment fail. After boot, if a local gateway exits, the application keeps the interface open while it looks for a validated replacement or relaunches the installed sibling with bounded backoff. A replacement is published only after its process identity, health response, and bearer key all validate, and the server switches its clients and credentials together. Explicitly configured gateways on another machine are never launched, supervised, or stopped by the Workshop. If microphone setup fails at startup, you keep working and only voice input stays unavailable. On Windows, if the bridge to Explorer fails to attach, the application keeps running and loses only Explorer drag-and-drop and the microphone grant. ## The gateway configuration @@ -68,7 +69,7 @@ You configure the Workshop through a TOML file named `workshop.toml`. The applic The keys you are most likely to set: -- `gateway.base_url` points the Workshop at a PromptForge gateway the connection file cannot see, such as one on another machine. When the value is empty, the Workshop attaches to a locally running gateway through its connection file, and with no gateway running, startup fails with an error that names both remedies. +- `gateway.base_url` points the Workshop at a PromptForge gateway the connection file cannot see, such as one on another machine. When the value is empty, the Workshop attaches to a locally running gateway through its connection file or launches the sibling `promptforge-gateway`. A Workshop-only install has no sibling, so with neither a running gateway nor an explicit value, startup fails with an error that names both remedies. - `gateway.api_key` supplies the bearer key for the gateway API. An empty key sends no `Authorization` header, which is right for a gateway running with authentication disabled. - `server.bind` is honored only by the standalone `workshop-server` binary. The desktop application owns its listener and always binds `127.0.0.1` on an OS-assigned port. - `server.state_dir` chooses where the Workshop keeps persistent state. Agent session event logs live under `state_dir/sessions/`, and the per-profile model memory is written there. It defaults to the config file's own directory. diff --git a/vibe/archdoc-next.md b/vibe/archdoc-next.md index 31606d3d..306849c0 100644 --- a/vibe/archdoc-next.md +++ b/vibe/archdoc-next.md @@ -219,3 +219,6 @@ N90 | observation | oversized-unit @ crates/workshop-server/src/gateway_binding/ N91 | observation | oversized-unit @ crates/workshop-server/src/gateway_binding/tests/shutdown.rs::assert_shutdown_target: adds a 78-line deterministic race harness | Route quit through the current Gateway snapshot N92 | observation | dispatch-on-tag @ crates/workshop-server/src/gateway_binding/tests/shutdown.rs::assert_shutdown_target: selects deterministic publication order through PublicationOrder | Route quit through the current Gateway snapshot N93 | observation | oversized-unit @ crates/workshop-server/src/test_gateway.rs: extends the named Gateway fixture to 197 lines with shutdown observation | Route quit through the current Gateway snapshot +N94 | observation | Workshop sidecar lifecycle modules @ crates/workshop/src/gateway: boot depends on validated identity, while supervision depends on boot and identity with no reverse dependencies | Split and ratchet sidecar lifecycle ownership +N95 | observation | Workshop module ceilings @ crates/workshop/module-ceilings.toml: every Rust source and test module has one recorded physical-line ceiling | Split and ratchet sidecar lifecycle ownership +N96 | observation | Workshop validated Gateway fixture @ crates/workshop-server/src/test_gateway.rs: workshop-server owns the named child process reused by Workshop recovery tests | Split and ratchet sidecar lifecycle ownership