diff --git a/Cargo.lock b/Cargo.lock index b429be87b..3e63f2c58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -168,6 +168,34 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "api-macros" +version = "0.1.0" +dependencies = [ + "api-macros-impl", + "axum", + "futures", + "oas3", + "reqwest", + "schemars", + "serde", + "serde_json", + "serde_urlencoded", + "tokio", + "trybuild", + "url", +] + +[[package]] +name = "api-macros-impl" +version = "0.1.0" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "arc-swap" version = "1.9.1" @@ -624,15 +652,16 @@ dependencies = [ "futures", "protocol", "reqwest", + "runtime-api", "serde", "serde_json", + "server-api", "tempfile", "thiserror 2.0.18", "ticket", "tokio", "tokio-tungstenite 0.29.0", "uuid", - "workspace-api", ] [[package]] @@ -1058,6 +1087,7 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.117", + "unicode-xid", ] [[package]] @@ -2778,6 +2808,24 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f" +[[package]] +name = "oas3" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da5a5aa72eddcc53edfd06f287a2c10f872d88e8b72c650234cd8a227572424a" +dependencies = [ + "derive_more", + "http", + "indexmap", + "log", + "once_cell", + "regex", + "semver", + "serde", + "serde_json", + "url", +] + [[package]] name = "oid-registry" version = "0.7.1" @@ -3238,6 +3286,15 @@ dependencies = [ "elliptic-curve", ] +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -3671,6 +3728,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "runtime-api" +version = "0.1.0" +dependencies = [ + "api-macros", + "manifest", + "protocol", + "serde", + "serde_json", + "tokio", + "workdir", +] + [[package]] name = "rusqlite" version = "0.37.0" @@ -4080,6 +4150,23 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "server-api" +version = "0.1.0" +dependencies = [ + "api-macros", + "protocol", + "runtime-api", + "schemars", + "serde", + "serde_json", + "sha2 0.11.0", + "tokio", + "ts-rs", + "webauthn-rs-proto", + "workdir", +] + [[package]] name = "session-analytics" version = "0.1.0" @@ -4107,8 +4194,10 @@ dependencies = [ "agen", "async-trait", "base64 0.22.1", + "filetime", "fs4", "futures", + "libc", "protocol", "serde", "serde_json", @@ -4827,6 +4916,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + [[package]] name = "toml_parser" version = "1.1.2+spec-1.1.0" @@ -5883,6 +5984,9 @@ name = "winnow" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] [[package]] name = "wiremock" @@ -6010,7 +6114,6 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", - "workspace-api", ] [[package]] @@ -6043,6 +6146,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "server-api", "session-metrics", "session-store", "sha2 0.11.0", @@ -6057,7 +6161,6 @@ dependencies = [ "tungstenite 0.28.0", "uuid", "workdir", - "workspace-api", ] [[package]] @@ -6076,9 +6179,11 @@ dependencies = [ "protocol", "reqwest", "ring", + "runtime-api", "serde", "serde_json", "serial_test", + "server-api", "session-store", "sha2 0.11.0", "tar", @@ -6094,21 +6199,9 @@ dependencies = [ "uuid", "workdir", "worker", - "workspace-api", "zeroize", ] -[[package]] -name = "workspace-api" -version = "0.1.0" -dependencies = [ - "protocol", - "serde", - "serde_json", - "ts-rs", - "webauthn-rs-proto", -] - [[package]] name = "writeable" version = "0.6.3" @@ -6171,6 +6264,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "server-api", "session-analytics", "session-store", "tempfile", @@ -6180,7 +6274,6 @@ dependencies = [ "tui", "uuid", "worker", - "workspace-api", ] [[package]] @@ -6222,10 +6315,12 @@ dependencies = [ "protocol", "reqwest", "ring", + "runtime-api", "rusqlite", "serde", "serde_json", "serde_yaml", + "server-api", "sha2 0.11.0", "ssh-key", "tempfile", @@ -6244,7 +6339,6 @@ dependencies = [ "workdir", "worker", "worker-runtime", - "workspace-api", "zeroize", ] diff --git a/Cargo.toml b/Cargo.toml index 4b922b316..9c284b62e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,8 @@ members = [ "crates/client", "crates/agen", "crates/agen-macros", + "crates/api-macros", + "crates/api-macros-impl", "crates/session-store", "crates/standalone", "crates/secrets", @@ -11,6 +13,8 @@ members = [ "crates/mcp", "crates/worker", "crates/worker-runtime", + "crates/runtime-api", + "crates/server-api", "crates/plugin-pdk", "crates/yoi", "crates/protocol", @@ -28,7 +32,6 @@ members = [ "crates/ticket", "crates/merge-request", "crates/project-record", - "crates/workspace-api", "crates/workspace-server", "tests/e2e", ] @@ -36,6 +39,8 @@ default-members = [ "crates/client", "crates/agen", "crates/agen-macros", + "crates/api-macros", + "crates/api-macros-impl", "crates/session-store", "crates/standalone", "crates/secrets", @@ -43,6 +48,8 @@ default-members = [ "crates/mcp", "crates/worker", "crates/worker-runtime", + "crates/runtime-api", + "crates/server-api", "crates/plugin-pdk", "crates/yoi", "crates/protocol", @@ -60,7 +67,6 @@ default-members = [ "crates/ticket", "crates/merge-request", "crates/project-record", - "crates/workspace-api", "crates/workspace-server", ] @@ -79,6 +85,7 @@ debug = false client = { path = "crates/client" } agen = { path = "crates/agen", version = "0.2" } agen-macros = { path = "crates/agen-macros", version = "0.2" } +api-macros = { path = "crates/api-macros" } manifest = { path = "crates/manifest" } mcp = { path = "crates/mcp" } lint-common = { path = "crates/lint-common" } @@ -88,7 +95,6 @@ ticket = { path = "crates/ticket" } project-record = { path = "crates/project-record" } worker = { path = "crates/worker" } worker-runtime = { path = "crates/worker-runtime" } -workspace-api = { path = "crates/workspace-api" } yoi-plugin-pdk = { path = "crates/plugin-pdk" } yoi = { path = "crates/yoi" } protocol = { path = "crates/protocol" } @@ -96,6 +102,8 @@ session-metrics = { path = "crates/session-metrics" } session-analytics = { path = "crates/session-analytics" } session-store = { path = "crates/session-store" } standalone = { path = "crates/standalone" } +runtime-api = { path = "crates/runtime-api" } +server-api = { path = "crates/server-api" } secrets = { path = "crates/secrets" } tools = { path = "crates/tools" } config-source = { path = "crates/config-source" } diff --git a/crates/api-macros-impl/Cargo.toml b/crates/api-macros-impl/Cargo.toml new file mode 100644 index 000000000..bdfd51553 --- /dev/null +++ b/crates/api-macros-impl/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "api-macros-impl" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish = false + +[lib] +proc-macro = true + +[features] +default = [] +openapi = [] + +[dependencies] +proc-macro-crate = "3" +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full", "extra-traits"] } diff --git a/crates/api-macros-impl/src/lib.rs b/crates/api-macros-impl/src/lib.rs new file mode 100644 index 000000000..2395767ef --- /dev/null +++ b/crates/api-macros-impl/src/lib.rs @@ -0,0 +1,1803 @@ +//! Implementation of `api-macros`. + +use std::collections::{BTreeMap, BTreeSet}; + +use proc_macro::TokenStream; +use proc_macro_crate::{FoundCrate, crate_name}; +use proc_macro2::Span; +use quote::{format_ident, quote}; +use syn::{ + Attribute, FnArg, GenericArgument, Ident, ItemTrait, LitInt, LitStr, Pat, PathArguments, + ReturnType, Token, TraitItem, TraitItemFn, Type, Visibility, + parse::{Parse, ParseStream}, + parse_macro_input, + punctuated::Punctuated, + spanned::Spanned, +}; + +#[proc_macro_attribute] +pub fn api(attr: TokenStream, item: TokenStream) -> TokenStream { + let attr = proc_macro2::TokenStream::from(attr); + let item = parse_macro_input!(item as ItemTrait); + + normalize_api(attr, item) + .and_then(expand_api) + .unwrap_or_else(syn::Error::into_compile_error) + .into() +} + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +enum Method { + Get, + Post, + Put, + Patch, + Delete, + Head, + Options, +} + +#[derive(Clone, Copy, Default)] +struct AdapterConfig { + reqwest: bool, + axum: bool, + openapi: bool, +} + +impl Parse for AdapterConfig { + fn parse(input: ParseStream<'_>) -> syn::Result { + let adapters = Punctuated::::parse_terminated(input)?; + let mut config = Self::default(); + for adapter in adapters { + match adapter.to_string().as_str() { + "reqwest" if !config.reqwest => config.reqwest = true, + "axum" if !config.axum => config.axum = true, + "openapi" if !config.openapi => config.openapi = true, + "reqwest" | "axum" | "openapi" => { + return Err(syn::Error::new(adapter.span(), "duplicate API adapter")); + } + _ => { + return Err(syn::Error::new( + adapter.span(), + "unsupported API adapter; expected `reqwest`, `axum`, or `openapi`", + )); + } + } + } + Ok(config) + } +} + +impl Method { + fn from_ident(ident: &Ident) -> Option { + match ident.to_string().as_str() { + "get" => Some(Self::Get), + "post" => Some(Self::Post), + "put" => Some(Self::Put), + "patch" => Some(Self::Patch), + "delete" => Some(Self::Delete), + "head" => Some(Self::Head), + "options" => Some(Self::Options), + _ => None, + } + } + + fn tokens(self, api_crate: &proc_macro2::TokenStream) -> proc_macro2::TokenStream { + let variant = match self { + Self::Get => quote!(Get), + Self::Post => quote!(Post), + Self::Put => quote!(Put), + Self::Patch => quote!(Patch), + Self::Delete => quote!(Delete), + Self::Head => quote!(Head), + Self::Options => quote!(Options), + }; + quote!(#api_crate::HttpMethod::#variant) + } + + fn permits_request_body(self) -> bool { + matches!(self, Self::Post | Self::Put | Self::Patch | Self::Delete) + } +} + +struct RouteArgs { + path: LitStr, + operation_id: Option, + status: Option, + alternate_status: Option, + error_status: Option, + additional_error_statuses: Vec, + bearer_auth: Option, + browser_auth: Option, + normalize_body_errors: Option, + openapi: Option, +} + +impl Parse for RouteArgs { + fn parse(input: ParseStream<'_>) -> syn::Result { + let path = input.parse()?; + let mut result = Self { + path, + operation_id: None, + status: None, + alternate_status: None, + error_status: None, + additional_error_statuses: Vec::new(), + bearer_auth: None, + browser_auth: None, + normalize_body_errors: None, + openapi: None, + }; + + while !input.is_empty() { + input.parse::()?; + if input.is_empty() { + break; + } + let key: Ident = input.parse()?; + input.parse::()?; + match key.to_string().as_str() { + "operation_id" => set_once( + &mut result.operation_id, + input.parse::()?, + &key, + "operation_id", + )?, + "status" => set_once(&mut result.status, input.parse::()?, &key, "status")?, + "alternate_status" => set_once( + &mut result.alternate_status, + input.parse::()?, + &key, + "alternate_status", + )?, + "error_status" => set_once( + &mut result.error_status, + input.parse::()?, + &key, + "error_status", + )?, + "additional_error_statuses" => { + if !result.additional_error_statuses.is_empty() { + return Err(syn::Error::new( + key.span(), + "duplicate `additional_error_statuses` route option", + )); + } + let content; + syn::bracketed!(content in input); + while !content.is_empty() { + result.additional_error_statuses.push(content.parse()?); + if !content.is_empty() { + content.parse::()?; + } + } + } + "bearer_auth" => set_once( + &mut result.bearer_auth, + input.parse::()?, + &key, + "bearer_auth", + )?, + "browser_auth" => set_once( + &mut result.browser_auth, + input.parse::()?, + &key, + "browser_auth", + )?, + "normalize_body_errors" => set_once( + &mut result.normalize_body_errors, + input.parse::()?, + &key, + "normalize_body_errors", + )?, + "openapi" => set_once( + &mut result.openapi, + input.parse::()?, + &key, + "openapi", + )?, + _ => { + return Err(syn::Error::new( + key.span(), + "unsupported route option; expected operation_id, status, or error_status", + )); + } + } + } + + Ok(result) + } +} + +fn set_once(slot: &mut Option, value: T, key: &Ident, display: &str) -> syn::Result<()> { + if slot.replace(value).is_some() { + return Err(syn::Error::new( + key.span(), + format!("duplicate `{display}` route option"), + )); + } + Ok(()) +} + +#[derive(Clone, Copy)] +enum Location { + Path, + Query, + Header, + Body, + /// Trusted request-local context supplied by the server adapter. Extension + /// parameters are not part of the wire contract or generated client signature. + Extension, +} + +impl Location { + fn tokens(self, api_crate: &proc_macro2::TokenStream) -> proc_macro2::TokenStream { + let variant = match self { + Self::Path => quote!(Path), + Self::Query => quote!(Query), + Self::Header => quote!(Header), + Self::Body => quote!(Body), + Self::Extension => unreachable!("extensions are not wire parameters"), + }; + quote!(#api_crate::ParameterLocation::#variant) + } +} + +struct Parameter { + rust_ident: Ident, + rust_name: String, + wire_name: String, + location: Location, + ty: Type, +} + +struct Operation { + method_ident: Ident, + marker_ident: Ident, + operation_id: String, + method: Method, + path: String, + parameters: Vec, + request_body: Option, + response_body: Option, + error_body: Option, + fallible: bool, + response_status: u16, + alternate_status: Option, + error_status: Option, + additional_error_statuses: Vec, + bearer_auth: bool, + browser_auth: bool, + normalize_body_errors: bool, + openapi_skip: bool, +} + +struct ApiDefinition { + item: ItemTrait, + metadata_ident: Ident, + operations_module_ident: Ident, + operations: Vec, + adapters: AdapterConfig, +} + +fn normalize_api( + attr: proc_macro2::TokenStream, + mut item: ItemTrait, +) -> syn::Result { + let adapters = syn::parse2::(attr)?; + if !matches!(item.vis, Visibility::Public(_)) { + return Err(syn::Error::new_spanned( + &item.ident, + "an #[api] trait must be public", + )); + } + if !item.generics.params.is_empty() || item.generics.where_clause.is_some() { + return Err(syn::Error::new_spanned( + &item.generics, + "an #[api] trait cannot be generic", + )); + } + + let trait_ident = item.ident.clone(); + let metadata_ident = format_ident!("{}Metadata", ident_text(&trait_ident)); + let operations_module_ident = format_ident!("{}_operations", to_snake_case(&trait_ident)); + let mut operations = Vec::new(); + let mut operation_ids = BTreeMap::::new(); + let mut routes = BTreeMap::<(Method, String), Span>::new(); + let mut marker_names = BTreeMap::::new(); + + for trait_item in &mut item.items { + let TraitItem::Fn(method) = trait_item else { + return Err(syn::Error::new_spanned( + trait_item, + "an #[api] trait may contain methods only", + )); + }; + let operation = normalize_operation(method)?; + + let marker_name = operation.marker_ident.to_string(); + if let Some((first_method, first_span)) = marker_names.insert( + marker_name.clone(), + (ident_text(&method.sig.ident), method.sig.ident.span()), + ) { + let mut error = syn::Error::new( + method.sig.ident.span(), + format!( + "generated operation marker `{marker_name}` collides with method `{first_method}`" + ), + ); + error.combine(syn::Error::new( + first_span, + format!("first method generating marker `{marker_name}`"), + )); + return Err(error); + } + + if operation_ids + .insert(operation.operation_id.clone(), method.sig.ident.span()) + .is_some() + { + return Err(syn::Error::new( + method.sig.ident.span(), + format!("duplicate operation ID `{}`", operation.operation_id), + )); + } + if routes + .insert( + (operation.method, operation.path.clone()), + method.sig.ident.span(), + ) + .is_some() + { + return Err(syn::Error::new( + method.sig.ident.span(), + format!( + "duplicate route for {} `{}`", + method_name(operation.method), + operation.path + ), + )); + } + operations.push(operation); + } + + if operations.is_empty() { + return Err(syn::Error::new_spanned( + &item.ident, + "an #[api] trait must declare at least one operation", + )); + } + + operations.sort_by(|left, right| left.operation_id.cmp(&right.operation_id)); + + if adapters.axum || adapters.reqwest { + make_service_futures_send(&mut item)?; + } + + Ok(ApiDefinition { + item, + metadata_ident, + operations_module_ident, + operations, + adapters, + }) +} + +fn make_service_futures_send(item: &mut ItemTrait) -> syn::Result<()> { + for trait_item in &mut item.items { + let TraitItem::Fn(method) = trait_item else { + continue; + }; + method.sig.asyncness = None; + let output = match &method.sig.output { + ReturnType::Default => quote!(()), + ReturnType::Type(_, ty) => quote!(#ty), + }; + method.sig.output = syn::parse2(quote!( + -> impl ::core::future::Future + Send + ))?; + } + Ok(()) +} + +fn normalize_operation(method: &mut TraitItemFn) -> syn::Result { + validate_signature(method)?; + + let mut route = None; + let mut retained_attrs = Vec::new(); + for attr in std::mem::take(&mut method.attrs) { + let maybe_method = attr.path().get_ident().and_then(Method::from_ident); + if let Some(http_method) = maybe_method { + if route.is_some() { + return Err(syn::Error::new_spanned( + attr, + "an API method must have exactly one HTTP method attribute", + )); + } + let args = attr.parse_args::()?; + route = Some((http_method, args)); + } else { + retained_attrs.push(attr); + } + } + method.attrs = retained_attrs; + + let (http_method, route) = route.ok_or_else(|| { + syn::Error::new_spanned( + &method.sig.ident, + "an API method requires one of #[get], #[post], #[put], #[patch], #[delete], #[head], or #[options]", + ) + })?; + + let path = route.path.value(); + let placeholders = parse_path_template(&route.path)?; + let operation_id = route + .operation_id + .as_ref() + .map(LitStr::value) + .unwrap_or_else(|| ident_text(&method.sig.ident)); + validate_operation_id( + &operation_id, + route.operation_id.as_ref().unwrap_or(&route.path), + )?; + + let (response_body, error_body, fallible) = parse_return_type(&method.sig.output)?; + if let Some(ty) = response_body.as_ref() { + validate_named_body_type(ty, "response")?; + } + if let Some(ty) = error_body.as_ref() { + validate_named_body_type(ty, "error response")?; + } + + let response_status = parse_status(route.status.as_ref(), "status")? + .unwrap_or(if response_body.is_some() { 200 } else { 204 }); + if !(200..=299).contains(&response_status) { + return Err(syn::Error::new( + route + .status + .as_ref() + .map_or(route.path.span(), Spanned::span), + "status must be a success status between 200 and 299", + )); + } + let alternate_status = parse_status(route.alternate_status.as_ref(), "alternate_status")?; + if let Some(status) = alternate_status + && (!(200..=299).contains(&status) || status == response_status) + { + return Err(syn::Error::new_spanned( + route.alternate_status, + "alternate_status must be a distinct success status between 200 and 299", + )); + } + if alternate_status.is_some() && response_body.is_none() { + return Err(syn::Error::new_spanned( + route.alternate_status, + "alternate_status requires a response body implementing HttpSuccess", + )); + } + let error_status = parse_status(route.error_status.as_ref(), "error_status")?; + if error_status.is_some() && error_body.is_none() { + return Err(syn::Error::new_spanned( + route.error_status, + "error_status requires a public error response type", + )); + } + if let Some(status) = error_status + && status < 400 + { + return Err(syn::Error::new_spanned( + route.error_status, + "error_status must be between 400 and 599", + )); + } + let error_status = error_body.as_ref().map(|_| error_status.unwrap_or(400)); + let mut additional_error_statuses = Vec::new(); + for status in &route.additional_error_statuses { + let value = status.base10_parse::().map_err(|_| { + syn::Error::new( + status.span(), + "HTTP status must be an unsigned 16-bit integer", + ) + })?; + if !(400..=599).contains(&value) { + return Err(syn::Error::new( + status.span(), + "additional error statuses must be between 400 and 599", + )); + } + if error_status == Some(value) || additional_error_statuses.contains(&value) { + return Err(syn::Error::new( + status.span(), + "additional error statuses must be distinct", + )); + } + additional_error_statuses.push(value); + } + if !additional_error_statuses.is_empty() && error_body.is_none() { + return Err(syn::Error::new_spanned( + &route.path, + "additional_error_statuses require a public error response type", + )); + } + + if http_method == Method::Head && response_body.is_some() { + return Err(syn::Error::new_spanned( + &method.sig.output, + "HEAD operations cannot declare a response body", + )); + } + if matches!(response_status, 204 | 205) && response_body.is_some() { + return Err(syn::Error::new_spanned( + &method.sig.output, + format!("status {response_status} cannot have a response body"), + )); + } + + let mut parameters = Vec::new(); + let mut body_type = None; + let mut path_parameters = BTreeSet::new(); + for input in &mut method.sig.inputs { + let FnArg::Typed(input) = input else { + continue; + }; + let ident = argument_ident(&input.pat)?; + let rust_name = ident_text(ident); + let explicit = take_location(&mut input.attrs)?; + let (location, wire_name) = match explicit { + Some((Location::Path, wire_name)) => { + if wire_name.is_some() { + return Err(syn::Error::new( + ident.span(), + "#[path] does not accept a wire name", + )); + } + (Location::Path, rust_name.clone()) + } + Some((Location::Query, wire_name)) => { + if wire_name.is_some() { + return Err(syn::Error::new( + ident.span(), + "#[query] does not accept a wire name", + )); + } + (Location::Query, rust_name.clone()) + } + Some((Location::Header, wire_name)) => ( + Location::Header, + wire_name.unwrap_or_else(|| rust_name.clone()), + ), + Some((Location::Body, wire_name)) => { + if wire_name.is_some() { + return Err(syn::Error::new( + ident.span(), + "#[body] does not accept a wire name", + )); + } + (Location::Body, rust_name.clone()) + } + Some((Location::Extension, wire_name)) => { + if wire_name.is_some() { + return Err(syn::Error::new( + ident.span(), + "#[extension] does not accept a wire name", + )); + } + (Location::Extension, rust_name.clone()) + } + None if placeholders.contains(&rust_name) => (Location::Path, rust_name.clone()), + None => { + return Err(syn::Error::new( + ident.span(), + "API arguments must use #[path], #[query], #[header], #[body], or #[extension]; path arguments may omit #[path] when their name matches a placeholder", + )); + } + }; + + if matches!(location, Location::Path) { + path_parameters.insert(rust_name.clone()); + } + if matches!(location, Location::Body) { + if body_type.is_some() { + return Err(syn::Error::new( + ident.span(), + "an API operation may declare only one request body", + )); + } + validate_named_body_type(&input.ty, "request")?; + body_type = Some((*input.ty).clone()); + } + parameters.push(Parameter { + rust_ident: ident.clone(), + rust_name, + wire_name, + location, + ty: (*input.ty).clone(), + }); + } + + let missing: Vec<_> = placeholders.difference(&path_parameters).cloned().collect(); + if !missing.is_empty() { + return Err(syn::Error::new( + route.path.span(), + format!( + "path placeholder(s) have no matching path parameter: {}", + missing.join(", ") + ), + )); + } + let extra: Vec<_> = path_parameters.difference(&placeholders).cloned().collect(); + if !extra.is_empty() { + return Err(syn::Error::new( + route.path.span(), + format!( + "path parameter(s) have no matching placeholder: {}", + extra.join(", ") + ), + )); + } + if body_type.is_some() && !http_method.permits_request_body() { + return Err(syn::Error::new_spanned( + &method.sig.ident, + format!( + "{} operations cannot declare a request body", + method_name(http_method) + ), + )); + } + + Ok(Operation { + marker_ident: operation_marker_ident(&method.sig.ident), + method_ident: method.sig.ident.clone(), + operation_id, + method: http_method, + path, + parameters, + request_body: body_type, + response_body, + error_body, + fallible, + response_status, + alternate_status, + error_status, + additional_error_statuses, + bearer_auth: route.bearer_auth.as_ref().is_some_and(|value| value.value), + browser_auth: route.browser_auth.as_ref().is_some_and(|value| value.value), + normalize_body_errors: route + .normalize_body_errors + .as_ref() + .is_some_and(|value| value.value), + openapi_skip: route.openapi.as_ref().is_some_and(|value| !value.value), + }) +} + +fn validate_signature(method: &TraitItemFn) -> syn::Result<()> { + let sig = &method.sig; + if sig.asyncness.is_none() { + return Err(syn::Error::new_spanned( + &sig.fn_token, + "API methods must be async", + )); + } + if sig.unsafety.is_some() + || sig.abi.is_some() + || sig.variadic.is_some() + || !sig.generics.params.is_empty() + || sig.generics.where_clause.is_some() + { + return Err(syn::Error::new_spanned( + sig, + "API methods cannot be unsafe, extern, variadic, or generic", + )); + } + if method.default.is_some() { + return Err(syn::Error::new_spanned( + method, + "API methods cannot provide a default implementation", + )); + } + match sig.inputs.first() { + Some(FnArg::Receiver(receiver)) + if receiver.reference.is_some() && receiver.mutability.is_none() => {} + _ => { + return Err(syn::Error::new_spanned( + &sig.inputs, + "API methods must take `&self` as their first argument", + )); + } + } + Ok(()) +} + +fn take_location(attrs: &mut Vec) -> syn::Result)>> { + let mut result = None; + let mut retained = Vec::new(); + for attr in std::mem::take(attrs) { + let location = match attr.path().get_ident().map(ToString::to_string).as_deref() { + Some("path") => Some(Location::Path), + Some("query") => Some(Location::Query), + Some("header") => Some(Location::Header), + Some("body") => Some(Location::Body), + Some("extension") => Some(Location::Extension), + Some("stream") | Some("multipart") | Some("binary") => { + return Err(syn::Error::new_spanned( + attr, + "this wire kind is explicitly unsupported by the initial API contract", + )); + } + _ => None, + }; + let Some(location) = location else { + retained.push(attr); + continue; + }; + if result.is_some() { + return Err(syn::Error::new_spanned( + attr, + "an API argument may have only one location attribute", + )); + } + let wire_name = if matches!(location, Location::Header) { + if matches!(attr.meta, syn::Meta::Path(_)) { + None + } else { + Some(attr.parse_args::()?.value()) + } + } else if matches!(attr.meta, syn::Meta::Path(_)) { + None + } else { + return Err(syn::Error::new_spanned( + attr, + "only #[header(\"wire-name\")] accepts an argument", + )); + }; + result = Some((location, wire_name)); + } + *attrs = retained; + Ok(result) +} + +fn parse_path_template(path: &LitStr) -> syn::Result> { + let value = path.value(); + if !value.starts_with('/') { + return Err(syn::Error::new( + path.span(), + "API paths must start with `/`", + )); + } + if value.contains('?') || value.contains('#') { + return Err(syn::Error::new( + path.span(), + "API path templates cannot contain a query string or fragment", + )); + } + + let mut placeholders = BTreeSet::new(); + let bytes = value.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'{' => { + let rest = &value[index + 1..]; + let Some(end) = rest.find('}') else { + return Err(syn::Error::new(path.span(), "unclosed path placeholder")); + }; + let name = &rest[..end]; + if name.is_empty() + || !name.chars().enumerate().all(|(index, ch)| { + ch == '_' + || ch.is_ascii_alphanumeric() && (index > 0 || !ch.is_ascii_digit()) + }) + { + return Err(syn::Error::new( + path.span(), + "path placeholders must be valid Rust identifiers", + )); + } + if !placeholders.insert(name.to_owned()) { + return Err(syn::Error::new( + path.span(), + format!("duplicate path placeholder `{name}`"), + )); + } + index += end + 2; + } + b'}' => { + return Err(syn::Error::new( + path.span(), + "path template contains an unmatched `}`", + )); + } + _ => index += 1, + } + } + Ok(placeholders) +} + +fn validate_operation_id(operation_id: &str, source: &impl Spanned) -> syn::Result<()> { + if operation_id.is_empty() + || !operation_id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_' | ':' | '/')) + { + return Err(syn::Error::new( + source.span(), + "operation_id must be non-empty and contain only ASCII letters, digits, `.`, `-`, `_`, `:`, or `/`", + )); + } + Ok(()) +} + +fn parse_status(value: Option<&LitInt>, name: &str) -> syn::Result> { + let Some(value) = value else { + return Ok(None); + }; + let status = value.base10_parse::()?; + if !(100..=599).contains(&status) { + return Err(syn::Error::new( + value.span(), + format!("{name} must be an HTTP status between 100 and 599"), + )); + } + Ok(Some(status)) +} + +fn parse_return_type(output: &ReturnType) -> syn::Result<(Option, Option, bool)> { + let ReturnType::Type(_, ty) = output else { + return Ok((None, None, false)); + }; + if is_unit(ty) { + return Ok((None, None, false)); + } + + if let Type::Path(type_path) = ty.as_ref() + && let Some(last) = type_path.path.segments.last() + && (last.ident == "Result" || last.ident == "ApiResult") + && let PathArguments::AngleBracketed(arguments) = &last.arguments + { + let types: Vec<_> = arguments + .args + .iter() + .filter_map(|argument| match argument { + GenericArgument::Type(ty) => Some(ty.clone()), + _ => None, + }) + .collect(); + return match types.as_slice() { + [response] => Ok((body_type(response), None, true)), + [response, error] => Ok((body_type(response), body_type(error), true)), + _ => Err(syn::Error::new_spanned( + arguments, + "API result types must have one response type and at most one error type", + )), + }; + } + + Ok((Some((**ty).clone()), None, false)) +} + +fn body_type(ty: &Type) -> Option { + (!is_unit(ty)).then(|| ty.clone()) +} + +fn is_unit(ty: &Type) -> bool { + matches!(ty, Type::Tuple(tuple) if tuple.elems.is_empty()) +} + +fn validate_named_body_type(ty: &Type, kind: &str) -> syn::Result<()> { + let Type::Path(path) = ty else { + return Err(syn::Error::new_spanned( + ty, + format!("{kind} bodies must use a named Rust type"), + )); + }; + if path.qself.is_some() { + return Err(syn::Error::new_spanned( + ty, + format!("{kind} bodies must use a direct named Rust type"), + )); + } + let last = path.path.segments.last().expect("type path has a segment"); + let builtin = matches!( + last.ident.to_string().as_str(), + "bool" + | "char" + | "str" + | "String" + | "u8" + | "u16" + | "u32" + | "u64" + | "u128" + | "usize" + | "i8" + | "i16" + | "i32" + | "i64" + | "i128" + | "isize" + | "f32" + | "f64" + | "Vec" + | "Option" + | "Box" + ); + if builtin { + return Err(syn::Error::new_spanned( + ty, + format!("{kind} bodies must use an explicit named DTO type"), + )); + } + Ok(()) +} + +fn argument_ident(pat: &Pat) -> syn::Result<&Ident> { + let Pat::Ident(ident) = pat else { + return Err(syn::Error::new_spanned( + pat, + "API arguments must use simple identifier patterns", + )); + }; + Ok(&ident.ident) +} + +fn api_crate_path() -> syn::Result { + match crate_name("api-macros") { + Ok(FoundCrate::Itself) => Ok(quote!(::api_macros)), + Ok(FoundCrate::Name(name)) => { + let ident = Ident::new(&name, Span::call_site()); + Ok(quote!(::#ident)) + } + Err(error) => Err(syn::Error::new( + Span::call_site(), + format!("could not resolve the api-macros support crate: {error}"), + )), + } +} + +fn reqwest_method_tokens( + method: Method, + api_crate: &proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + let method = Ident::new(method_name(method), Span::call_site()); + quote!(#api_crate::reqwest::framework::Method::#method) +} + +fn reqwest_path_segment_tokens(segment: &str, operation: &Operation) -> proc_macro2::TokenStream { + let mut pieces = Vec::new(); + let mut remaining = segment; + while let Some(start) = remaining.find('{') { + let literal = &remaining[..start]; + if !literal.is_empty() { + pieces.push(quote!(__segment.push_str(#literal);)); + } + let after = &remaining[start + 1..]; + let end = after + .find('}') + .expect("path placeholders are validated before adapter generation"); + let name = &after[..end]; + let parameter = operation + .parameters + .iter() + .find(|parameter| { + matches!(parameter.location, Location::Path) && parameter.rust_name == name + }) + .expect("path parameter names are validated before adapter generation"); + let ident = ¶meter.rust_ident; + pieces.push(quote! { + __segment.push_str(&::std::string::ToString::to_string(&#ident)); + }); + remaining = &after[end + 1..]; + } + if !remaining.is_empty() { + pieces.push(quote!(__segment.push_str(#remaining);)); + } + quote! {{ + let mut __segment = ::std::string::String::new(); + #(#pieces)* + __segment + }} +} + +fn reqwest_adapter_tokens( + api: &ApiDefinition, + api_crate: &proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + if !api.adapters.reqwest { + return quote!(); + } + let trait_ident = &api.item.ident; + let visibility = &api.item.vis; + let client_ident = format_ident!("{}Client", ident_text(trait_ident)); + let builder_ident = format_ident!("{}ClientBuilder", ident_text(trait_ident)); + let methods = api.operations.iter().map(|operation| { + let method_ident = &operation.method_ident; + let arguments = operation.parameters.iter().filter_map(|parameter| { + if matches!(parameter.location, Location::Extension) { + return None; + } + let ident = ¶meter.rust_ident; + let ty = ¶meter.ty; + Some(quote!(#ident: #ty)) + }); + let path_segments = operation + .path + .split('/') + .skip(1) + .map(|segment| reqwest_path_segment_tokens(segment, operation)); + let queries = operation.parameters.iter().filter_map(|parameter| { + if !matches!(parameter.location, Location::Query) { + return None; + } + let ident = ¶meter.rust_ident; + Some(quote! { + #api_crate::reqwest::append_query(&mut __url, &#ident) + .map_err(#api_crate::reqwest::ClientError::from)?; + }) + }); + let headers = operation.parameters.iter().filter_map(|parameter| { + if !matches!(parameter.location, Location::Header) { + return None; + } + let ident = ¶meter.rust_ident; + let name = ¶meter.wire_name; + Some(quote! { + #api_crate::reqwest::insert_header(&mut __headers, #name, &#ident) + .map_err(#api_crate::reqwest::ClientError::from)?; + }) + }); + let body = operation + .parameters + .iter() + .find(|parameter| matches!(parameter.location, Location::Body)) + .map(|parameter| { + let ident = ¶meter.rust_ident; + quote! { + ::core::option::Option::Some( + #api_crate::reqwest::encode_json(&#ident) + .map_err(#api_crate::reqwest::ClientError::from)?, + ) + } + }) + .unwrap_or_else(|| quote!(::core::option::Option::None)); + let method = reqwest_method_tokens(operation.method, api_crate); + let response_status = operation.response_status; + let accepted_status = operation + .alternate_status + .map(|alternate| quote!(__actual == #response_status || __actual == #alternate)) + .unwrap_or_else(|| quote!(__actual == #response_status)); + let response_type = operation + .response_body + .as_ref() + .map(|ty| quote!(#ty)) + .unwrap_or_else(|| quote!(())); + let error_type = operation + .error_body + .as_ref() + .map(|ty| quote!(#ty)) + .unwrap_or_else(|| quote!(#api_crate::NoBody)); + let success = if let Some(response) = operation.response_body.as_ref() { + quote! { + return __response + .decode_json::<#response>(#api_crate::reqwest::DecodeKind::Success) + .map_err(#api_crate::reqwest::ClientError::from); + } + } else { + quote! { + return __response + .require_empty() + .map_err(#api_crate::reqwest::ClientError::from); + } + }; + let public_error = match operation.error_body.as_ref() { + Some(error) => quote! { + if (400..=599).contains(&__actual) { + let __status = __response.status(); + let __error = __response + .decode_json::<#error>(#api_crate::reqwest::DecodeKind::PublicError) + .map_err(|_| #api_crate::reqwest::ClientError::failure( + #api_crate::reqwest::ClientFailure::ErrorResponseDecode { + status: __status.as_u16(), + }, + ))?; + return ::core::result::Result::Err( + #api_crate::reqwest::ClientError::public(__status, __error), + ); + } + }, + None => quote!(), + }; + quote! { + pub async fn #method_ident( + &self, + #(#arguments),* + ) -> ::core::result::Result<#response_type, #api_crate::reqwest::ClientError<#error_type>> { + let __segments = [#(#path_segments),*]; + let mut __url = self.core.endpoint(&__segments) + .map_err(#api_crate::reqwest::ClientError::from)?; + #(#queries)* + let mut __headers = #api_crate::reqwest::framework::header::HeaderMap::new(); + #(#headers)* + let __body = #body; + let __response = self.core.send(#method, __url, __headers, __body) + .await + .map_err(#api_crate::reqwest::ClientError::from)?; + let __actual = __response.status().as_u16(); + if #accepted_status { + #success + } + #public_error + ::core::result::Result::Err(#api_crate::reqwest::ClientError::from( + #api_crate::reqwest::ClientFailure::UnexpectedStatus { + expected: #response_status, + actual: __actual, + }, + )) + } + } + }); + + quote! { + #[doc = concat!("Typed Reqwest client for [`", stringify!(#trait_ident), "`].")] + #visibility struct #client_ident { + core: #api_crate::reqwest::ClientCore, + } + + impl #client_ident<#api_crate::reqwest::NoAuthorizer> { + pub fn try_new( + base_url: impl ::core::convert::AsRef, + ) -> ::core::result::Result { + Self::builder(base_url)?.build() + } + + pub fn builder( + base_url: impl ::core::convert::AsRef, + ) -> ::core::result::Result<#builder_ident, #api_crate::reqwest::ClientBuildError> { + Ok(#builder_ident { + inner: #api_crate::reqwest::ClientBuilder::try_new(base_url)?, + }) + } + + pub fn with_client( + base_url: #api_crate::reqwest::BaseUrl, + client: #api_crate::reqwest::framework::Client, + ) -> Self { + #builder_ident { + inner: #api_crate::reqwest::ClientBuilder::from_base_url(base_url).client(client), + } + .build() + .expect("an injected Reqwest client requires no fallible construction") + } + } + + impl #client_ident { + pub fn from_core(core: #api_crate::reqwest::ClientCore) -> Self { + Self { core } + } + } + + impl #client_ident { + #(#methods)* + } + + impl ::core::fmt::Debug for #client_ident { + fn fmt(&self, formatter: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + formatter + .debug_struct(stringify!(#client_ident)) + .field("core", &self.core) + .finish() + } + } + + #[doc = concat!("Builder for [`", stringify!(#client_ident), "`].")] + #visibility struct #builder_ident { + inner: #api_crate::reqwest::ClientBuilder, + } + + impl #builder_ident { + pub fn client(mut self, client: #api_crate::reqwest::framework::Client) -> Self { + self.inner = self.inner.client(client); + self + } + + pub fn authorizer(self, authorizer: B) -> #builder_ident { + #builder_ident { inner: self.inner.authorizer(authorizer) } + } + + pub fn request_timeout(mut self, timeout: ::std::time::Duration) -> Self { + self.inner = self.inner.request_timeout(timeout); + self + } + + pub fn response_body_limit(mut self, limit: usize) -> Self { + self.inner = self.inner.response_body_limit(limit); + self + } + + pub fn build( + self, + ) -> ::core::result::Result<#client_ident, #api_crate::reqwest::ClientBuildError> { + self.inner.build().map(#client_ident::from_core) + } + } + + impl ::core::fmt::Debug for #builder_ident { + fn fmt(&self, formatter: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + formatter + .debug_struct(stringify!(#builder_ident)) + .field("inner", &self.inner) + .finish() + } + } + } +} + +fn axum_routing_tokens( + method: Method, + api_crate: &proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + let function = match method { + Method::Get => quote!(get), + Method::Post => quote!(post), + Method::Put => quote!(put), + Method::Patch => quote!(patch), + Method::Delete => quote!(delete), + Method::Head => quote!(head), + Method::Options => quote!(options), + }; + quote!(#api_crate::axum::framework::#function) +} + +fn ordered_path_parameters<'a>(operation: &'a Operation) -> Vec<&'a Parameter> { + let mut result = Vec::new(); + let mut remaining = operation.path.as_str(); + while let Some(start) = remaining.find('{') { + let after = &remaining[start + 1..]; + let Some(end) = after.find('}') else { break }; + let name = &after[..end]; + if let Some(parameter) = operation.parameters.iter().find(|parameter| { + matches!(parameter.location, Location::Path) && parameter.rust_name == name + }) { + result.push(parameter); + } + remaining = &after[end + 1..]; + } + result +} + +fn axum_adapter_tokens( + api: &ApiDefinition, + api_crate: &proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + if !api.adapters.axum { + return quote!(); + } + let trait_ident = &api.item.ident; + let visibility = &api.item.vis; + let adapter_ident = format_ident!("{}Axum", ident_text(trait_ident)); + let module_ident = format_ident!("{}_axum", to_snake_case(trait_ident)); + let route_functions: Vec<_> = api.operations.iter().map(|operation| { + let method_ident = &operation.method_ident; + let handler_ident = format_ident!("{}_handler", ident_text(method_ident)); + let route_path = &operation.path; + let routing = axum_routing_tokens(operation.method, api_crate); + let path_parameters = ordered_path_parameters(operation); + let path_extractor = match path_parameters.as_slice() { + [] => quote!(), + [parameter] => { + let ident = ¶meter.rust_ident; + let ty = ¶meter.ty; + quote!(#api_crate::axum::framework::Path(#ident): #api_crate::axum::framework::Path<#ty>,) + } + parameters => { + let identifiers = parameters.iter().map(|parameter| ¶meter.rust_ident); + let types = parameters.iter().map(|parameter| ¶meter.ty); + quote!(#api_crate::axum::framework::Path((#(#identifiers),*)): #api_crate::axum::framework::Path<(#(#types),*)>,) + } + }; + let query_extractors = operation.parameters.iter().filter_map(|parameter| { + if !matches!(parameter.location, Location::Query) { + return None; + } + let ident = ¶meter.rust_ident; + let ty = ¶meter.ty; + Some(quote!(#api_crate::axum::framework::Query(#ident): #api_crate::axum::framework::Query<#ty>,)) + }); + let has_headers = operation.parameters.iter().any(|parameter| matches!(parameter.location, Location::Header)); + let header_extractor = has_headers.then(|| quote!(__headers: #api_crate::axum::framework::HeaderMap,)); + let header_parsers = operation.parameters.iter().filter_map(|parameter| { + if !matches!(parameter.location, Location::Header) { + return None; + } + let ident = ¶meter.rust_ident; + let ty = ¶meter.ty; + let name = ¶meter.wire_name; + Some(quote! { + let #ident: #ty = match #api_crate::axum::parse_header(&__headers, #name) { + Ok(value) => value, + Err(status) => return #api_crate::axum::rejection(status), + }; + }) + }); + let extension_extractors = operation.parameters.iter().filter_map(|parameter| { + if !matches!(parameter.location, Location::Extension) { + return None; + } + let ident = ¶meter.rust_ident; + let ty = ¶meter.ty; + Some(quote!(#api_crate::axum::framework::Extension(#ident): #api_crate::axum::framework::Extension<#ty>,)) + }); + let (body_extractor, body_rejection) = operation + .parameters + .iter() + .find(|parameter| matches!(parameter.location, Location::Body)) + .map(|parameter| { + let ident = ¶meter.rust_ident; + let ty = ¶meter.ty; + if !operation.normalize_body_errors { + return ( + Some(quote!(#api_crate::axum::framework::Json(#ident): #api_crate::axum::framework::Json<#ty>,)), + None, + ); + } + let error = operation + .error_body + .as_ref() + .expect("body operations require an error type"); + ( + Some(quote!( + __body: ::core::result::Result< + #api_crate::axum::framework::Json<#ty>, + #api_crate::axum::framework::JsonRejection, + >, + )), + Some(quote! { + let #ident = match __body { + ::core::result::Result::Ok(#api_crate::axum::framework::Json(value)) => value, + ::core::result::Result::Err(rejection) => { + let status = rejection.status().as_u16(); + let error = <#error as #api_crate::HttpRequestError>::from_request_rejection( + status, + rejection.body_text(), + ); + return #api_crate::axum::json_response( + #api_crate::axum::status(status), + error, + ); + } + }; + }), + ) + }) + .unwrap_or((None, None)); + let call_arguments = operation.parameters.iter().map(|parameter| ¶meter.rust_ident); + let response_status = operation.response_status; + let success = if let Some(alternate_status) = operation.alternate_status { + quote!({ + let status = #api_crate::HttpSuccess::status_code(&value); + if status == #response_status || status == #alternate_status { + #api_crate::axum::json_response(#api_crate::axum::status(status), value) + } else { + #api_crate::axum::empty_response(#api_crate::axum::status(500)) + } + }) + } else if operation.response_body.is_some() { + quote!(#api_crate::axum::json_response(#api_crate::axum::status(#response_status), value)) + } else { + quote!({ let _ = value; #api_crate::axum::empty_response(#api_crate::axum::status(#response_status)) }) + }; + let invocation = quote!(#trait_ident::#method_ident(&*__service, #(#call_arguments),*).await); + let mapped = if operation.fallible { + let error = match operation.error_status { + Some(_) => quote!({ + let status = #api_crate::HttpError::status_code(&error); + #api_crate::axum::json_response(#api_crate::axum::status(status), error) + }), + None => quote!({ let _ = error; #api_crate::axum::empty_response(#api_crate::axum::framework::StatusCode::INTERNAL_SERVER_ERROR) }), + }; + quote! { + match #invocation { + Ok(value) => #success, + Err(error) => #error, + } + } + } else { + quote! { + let value = #invocation; + #success + } + }; + quote! { + pub fn #method_ident(service: ::std::sync::Arc) -> #api_crate::axum::framework::Router + where + S: super::#trait_ident + Send + Sync + 'static, + { + #api_crate::axum::framework::Router::new() + .route(#route_path, #routing(#handler_ident::)) + .with_state(service) + } + + async fn #handler_ident( + #api_crate::axum::framework::State(__service): #api_crate::axum::framework::State<::std::sync::Arc>, + #path_extractor + #(#query_extractors)* + #header_extractor + #(#extension_extractors)* + #body_extractor + ) -> #api_crate::axum::framework::Response + where + S: super::#trait_ident + Send + Sync + 'static, + { + #(#header_parsers)* + #body_rejection + #mapped + } + } + }).collect(); + let operation_merges = api.operations.iter().map(|operation| { + let method_ident = &operation.method_ident; + quote!(__router = __router.merge(#module_ident::#method_ident(__service.clone()));) + }); + + quote! { + #[doc = concat!("Per-operation Axum routers for [`", stringify!(#trait_ident), "`].")] + #visibility mod #module_ident { + use super::*; + #(#route_functions)* + } + + #[doc = concat!("Axum router adapter for [`", stringify!(#trait_ident), "`].")] + #[derive(Clone, Copy, Debug, Default)] + #visibility struct #adapter_ident; + + impl #adapter_ident { + pub fn router(service: S) -> #api_crate::axum::framework::Router + where + S: #trait_ident + Send + Sync + 'static, + { + let __service = ::std::sync::Arc::new(service); + let mut __router = #api_crate::axum::framework::Router::new(); + #(#operation_merges)* + __router + } + } + } +} + +fn openapi_adapter_tokens( + api: &ApiDefinition, + api_crate: &proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + if !api.adapters.openapi { + return proc_macro2::TokenStream::new(); + } + + let function_ident = format_ident!( + "{}_openapi", + to_snake_case(&api.item.ident), + span = api.item.ident.span() + ); + let operations = api + .operations + .iter() + .filter(|operation| !operation.openapi_skip) + .map(|operation| { + let method = method_name(operation.method); + let path = &operation.path; + let operation_id = &operation.operation_id; + let parameters = operation.parameters.iter().filter_map(|parameter| { + if matches!(parameter.location, Location::Body | Location::Extension) { + return None; + } + let ty = ¶meter.ty; + let name = ¶meter.wire_name; + let location = match parameter.location { + Location::Path => "path", + Location::Query => "query", + Location::Header => "header", + Location::Body | Location::Extension => unreachable!(), + }; + Some(quote! { + operation.parameter::<#ty>(#name, #location)?; + }) + }); + let request = operation.request_body.as_ref().map(|ty| { + quote! { + operation.request_body::<#ty>("application/json")?; + } + }); + let status = operation.response_status; + let response = match &operation.response_body { + Some(ty) => quote! { + operation.response::<#ty>(#status, "application/json", "Successful response")?; + }, + None => quote! { + operation.empty_response(#status, "Successful response")?; + }, + }; + let alternate_response = operation.alternate_status.map(|status| { + match operation.response_body.as_ref() { + Some(ty) => quote! { + operation.response::<#ty>(#status, "application/json", "Alternate successful response")?; + }, + None => quote! { + operation.empty_response(#status, "Alternate successful response")?; + }, + } + }); + let error = operation + .error_status + .zip(operation.error_body.as_ref()) + .map(|(status, ty)| { + quote! { + operation.response::<#ty>(#status, "application/json", "Error response")?; + } + }); + let additional_errors = operation + .error_body + .as_ref() + .map(|ty| { + operation + .additional_error_statuses + .iter() + .map(|status| { + quote! { + operation.response::<#ty>(#status, "application/json", "Error response")?; + } + }) + .collect::>() + }) + .unwrap_or_default(); + let bearer_auth = operation.bearer_auth.then(|| { + quote! { operation.bearer_authentication(); } + }); + let browser_auth = operation.browser_auth.then(|| { + quote! { operation.browser_authentication(); } + }); + + quote! { + { + let mut operation = builder.operation(#method, #path, #operation_id)?; + #(#parameters)* + #bearer_auth + #browser_auth + #request + #response + #alternate_response + #error + #(#additional_errors)* + operation.finish()?; + } + } + }); + + quote! { + #[doc = "Build this trait's deterministic, deployment-independent OpenAPI 3.1 document."] + pub fn #function_ident( + info: #api_crate::openapi::OpenApiInfo<'_>, + ) -> ::core::result::Result< + #api_crate::openapi::OpenApiDocument, + #api_crate::openapi::OpenApiError, + > { + let mut builder = #api_crate::openapi::OpenApiBuilder::new(info)?; + #(#operations)* + builder.finish() + } + } +} + +fn expand_api(api: ApiDefinition) -> syn::Result { + let api_crate = api_crate_path()?; + let reqwest_adapter = reqwest_adapter_tokens(&api, &api_crate); + let axum_adapter = axum_adapter_tokens(&api, &api_crate); + let openapi_adapter = openapi_adapter_tokens(&api, &api_crate); + let item = api.item; + let metadata_ident = api.metadata_ident; + let module_ident = api.operations_module_ident; + + let marker_items: Vec<_> = api + .operations + .iter() + .map(|operation| { + let marker_ident = &operation.marker_ident; + let method_ident = &operation.method_ident; + quote! { + #[doc = concat!("Typed operation marker for Rust method `", stringify!(#method_ident), "`.")] + #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] + pub struct #marker_ident; + } + }) + .collect(); + let operation_impls: Vec<_> = api + .operations + .iter() + .map(|operation| { + let marker_ident = &operation.marker_ident; + let metadata = metadata_tokens(operation, &api_crate); + let parameter_types = operation.parameters.iter().filter_map(|parameter| { + if matches!(parameter.location, Location::Extension) { + None + } else { + Some(¶meter.ty) + } + }); + let request_type = operation + .request_body + .as_ref() + .map(|ty| quote!(#ty)) + .unwrap_or_else(|| quote!(#api_crate::NoBody)); + let response_type = operation + .response_body + .as_ref() + .map(|ty| quote!(#ty)) + .unwrap_or_else(|| quote!(#api_crate::NoBody)); + let error_type = operation + .error_body + .as_ref() + .map(|ty| quote!(#ty)) + .unwrap_or_else(|| quote!(#api_crate::NoBody)); + quote! { + impl #api_crate::Operation for #module_ident::#marker_ident { + type Parameters = (#(#parameter_types,)*); + type RequestBody = #request_type; + type ResponseBody = #response_type; + type ErrorBody = #error_type; + + const METADATA: #api_crate::OperationMetadata = #metadata; + } + } + }) + .collect(); + let inventory: Vec<_> = api + .operations + .iter() + .map(|operation| metadata_tokens(operation, &api_crate)) + .collect(); + + Ok(quote! { + #item + + #[doc = "Deterministic operation inventory for the API trait."] + #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)] + pub struct #metadata_ident; + + impl #api_crate::ApiContract for #metadata_ident { + const OPERATIONS: &'static [#api_crate::OperationMetadata] = &[ + #(#inventory),* + ]; + } + + #[doc = "Typed markers generated for this API trait's operations."] + pub mod #module_ident { + #(#marker_items)* + } + + #(#operation_impls)* + + #reqwest_adapter + #axum_adapter + #openapi_adapter + }) +} + +fn metadata_tokens( + operation: &Operation, + api_crate: &proc_macro2::TokenStream, +) -> proc_macro2::TokenStream { + let operation_id = &operation.operation_id; + let method = operation.method.tokens(api_crate); + let path = &operation.path; + let request_kind = if operation.request_body.is_some() { + quote!(#api_crate::WireKind::Json) + } else { + quote!(#api_crate::WireKind::Empty) + }; + let response_kind = if operation.response_body.is_some() { + quote!(#api_crate::WireKind::Json) + } else { + quote!(#api_crate::WireKind::Empty) + }; + let response_status = operation.response_status; + let error = match operation.error_status { + Some(status) => quote! { + ::core::option::Option::Some(#api_crate::ResponseMetadata { + status: #status, + body: #api_crate::BodyMetadata { + wire_kind: #api_crate::WireKind::Json, + }, + }) + }, + None => quote!(::core::option::Option::None), + }; + let parameters = operation.parameters.iter().filter_map(|parameter| { + if matches!(parameter.location, Location::Extension) { + return None; + } + let rust_name = ¶meter.rust_name; + let wire_name = ¶meter.wire_name; + let location = parameter.location.tokens(api_crate); + Some(quote! { + #api_crate::ParameterMetadata { + rust_name: #rust_name, + wire_name: #wire_name, + location: #location, + } + }) + }); + + quote! { + #api_crate::OperationMetadata { + operation_id: #operation_id, + method: #method, + path: #path, + parameters: &[#(#parameters),*], + request_body: #api_crate::BodyMetadata { wire_kind: #request_kind }, + response: #api_crate::ResponseMetadata { + status: #response_status, + body: #api_crate::BodyMetadata { wire_kind: #response_kind }, + }, + error_response: #error, + } + } +} + +fn method_name(method: Method) -> &'static str { + match method { + Method::Get => "GET", + Method::Post => "POST", + Method::Put => "PUT", + Method::Patch => "PATCH", + Method::Delete => "DELETE", + Method::Head => "HEAD", + Method::Options => "OPTIONS", + } +} + +fn ident_text(ident: &Ident) -> String { + let value = ident.to_string(); + value.strip_prefix("r#").unwrap_or(&value).to_owned() +} + +fn operation_marker_ident(method: &Ident) -> Ident { + let candidate = to_pascal_case(method); + if let Ok(mut marker) = syn::parse_str::(&candidate) { + marker.set_span(method.span()); + return marker; + } + + let encoded = ident_text(method) + .as_bytes() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + Ident::new(&format!("Operation{encoded}"), method.span()) +} + +fn to_pascal_case(ident: &Ident) -> String { + ident_text(ident) + .split('_') + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + chars + .next() + .map(|first| first.to_ascii_uppercase().to_string() + chars.as_str()) + .unwrap_or_default() + }) + .collect() +} + +fn to_snake_case(ident: &Ident) -> String { + let value = ident_text(ident); + let mut result = String::new(); + for (index, ch) in value.chars().enumerate() { + if ch.is_ascii_uppercase() { + if index > 0 { + result.push('_'); + } + result.push(ch.to_ascii_lowercase()); + } else { + result.push(ch); + } + } + result +} diff --git a/crates/api-macros/Cargo.toml b/crates/api-macros/Cargo.toml new file mode 100644 index 000000000..0bc19b435 --- /dev/null +++ b/crates/api-macros/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "api-macros" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish = false + +[features] +default = [] +axum = ["dep:axum", "dep:serde"] +openapi = ["dep:schemars", "dep:serde_json", "api-macros-impl/openapi"] +reqwest = [ + "dep:futures", + "dep:reqwest", + "dep:serde", + "dep:serde_json", + "dep:serde_urlencoded", + "dep:url", +] + +[dependencies] +api-macros-impl = { path = "../api-macros-impl" } +axum = { workspace = true, optional = true } +futures = { workspace = true, optional = true } +reqwest = { version = "0.13", default-features = false, optional = true, features = ["stream", "rustls"] } +schemars = { version = "1.2", optional = true } +serde = { workspace = true, optional = true } +serde_json = { workspace = true, optional = true } +serde_urlencoded = { version = "0.7", optional = true } +url = { version = "2", optional = true } + +[dev-dependencies] +oas3 = { version = "0.22", default-features = false } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tokio = { workspace = true, features = ["net"] } +trybuild = "1" diff --git a/crates/api-macros/src/axum.rs b/crates/api-macros/src/axum.rs new file mode 100644 index 000000000..aeaa3bfc7 --- /dev/null +++ b/crates/api-macros/src/axum.rs @@ -0,0 +1,61 @@ +//! Axum support used by routers generated with `#[api(axum)]`. +//! +//! Generated operation routers contain only transport extraction, typed service invocation, and +//! response mapping. Authentication/authorization context, body-limit policy, timeout policy, and +//! other application state remain explicit outer Axum layers. Each operation is also exposed as a +//! separate router so route-specific layers can be applied before routers are merged. + +use std::str::FromStr; + +use axum::{ + Json, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response}, +}; +use serde::Serialize; + +/// Parse one contract header into its declared Rust type. +pub fn parse_header(headers: &HeaderMap, name: &'static str) -> Result +where + T: FromStr, +{ + headers + .get(name) + .ok_or(StatusCode::BAD_REQUEST)? + .to_str() + .map_err(|_| StatusCode::BAD_REQUEST)? + .parse() + .map_err(|_| StatusCode::BAD_REQUEST) +} + +/// Serialize a typed JSON response with the contract status. +pub fn json_response(status: StatusCode, value: T) -> Response { + (status, Json(value)).into_response() +} + +/// Emit an empty response with the contract status. +pub fn empty_response(status: StatusCode) -> Response { + status.into_response() +} + +/// Emit a transport-level rejection without a response body. +pub fn rejection(status: StatusCode) -> Response { + status.into_response() +} + +/// Convert a status constant from generated metadata. +pub fn status(code: u16) -> StatusCode { + StatusCode::from_u16(code).expect("#[api] validates HTTP status constants") +} + +/// Reexports used by generated router code. +pub mod framework { + pub use axum::{ + Json, Router, + extract::{Extension, Path, Query, State, rejection::JsonRejection}, + http::{HeaderMap, StatusCode}, + response::Response, + routing::{delete, get, head, options, patch, post, put}, + serve, + }; +} diff --git a/crates/api-macros/src/lib.rs b/crates/api-macros/src/lib.rs new file mode 100644 index 000000000..318bad6ef --- /dev/null +++ b/crates/api-macros/src/lib.rs @@ -0,0 +1,225 @@ +//! Shared API trait declarations for Yoi services. +//! +//! `#[api]` turns a public trait into a single, transport-neutral API definition. The +//! original trait remains usable by Rust code; generated metadata and zero-sized operation +//! markers give adapters and generators a stable view of the same declaration. +//! +//! # Declaration syntax +//! +//! ``` +//! # mod example { +//! use api_macros::api; +//! +//! pub struct CreateWidget; +//! pub struct Widget; +//! pub struct PublicError; +//! +//! #[api] +//! pub trait WidgetApi { +//! #[post( +//! "/widgets", +//! operation_id = "widgets.create", +//! status = 201, +//! error_status = 422 +//! )] +//! async fn create( +//! &self, +//! #[body] request: CreateWidget, +//! #[header("x-request-id")] request_id: String, +//! ) -> Result; +//! +//! #[get("/widgets/{widget_id}", operation_id = "widgets.get")] +//! async fn get(&self, widget_id: u64) -> Result; +//! } +//! # } +//! ``` +//! +//! Supported route attributes are `get`, `post`, `put`, `patch`, `delete`, `head`, and +//! `options`. The first argument is a path template. `operation_id` defaults to the Rust +//! method name but should be set explicitly for contracts which must remain stable while Rust +//! names evolve. Success status defaults to `200`, except an empty (`()`) response defaults to +//! `204`. `alternate_status` opts a JSON response into [`HttpSuccess`] when two successful HTTP +//! outcomes share one schema. A public error inferred from `Result` defaults to status `400`; +//! `additional_error_statuses` publishes the same typed error schema for other declared outcomes. +//! `bearer_auth = true` and `browser_auth = true` attach standard bearer and browser-session cookie +//! security schemes. Body operations may opt into typed Axum rejection normalization with +//! typed Axum rejection normalization with `normalize_body_errors = true` and [`HttpRequestError`]. +//! +//! Arguments are classified with `#[body]`, `#[query]`, `#[header]`, `#[path]`, or `#[extension]`. +//! Extension values are trusted server-local Axum context: generated clients and OpenAPI omit them. +//! An unannotated argument whose Rust name occurs in the route template is inferred as a path +//! argument. Header attributes may carry a wire name, as in `#[header("x-request-id")]`. +//! Exactly one JSON body is allowed. JSON request, response, and error bodies must be named +//! Rust types; tuples, references, arrays, and other anonymous structural types are rejected. +//! `openapi = false` explicitly excludes an operation whose wire body cannot yet satisfy the +//! strict OpenAPI schema boundary; Reqwest and Axum adapters are still generated. +//! +//! # Generated names +//! +//! For `WidgetApi`, the macro emits `WidgetApiMetadata`, which implements [`ApiContract`], +//! plus a `widget_api_operations` module. That module contains one marker per Rust method +//! (`Create`, `Get`, ...), each implementing [`Operation`] and connecting metadata to concrete +//! body types at compile time. Unusual method names which have no valid PascalCase form use the +//! deterministic `Operation` fallback. The `ApiContract::OPERATIONS` inventory is +//! sorted by operation ID, so its ordering is independent of source method order. +//! +//! Only empty and JSON bodies are accepted by this first contract. [`WireKind`] reserves +//! explicit variants for future transport work; accepting one requires a deliberate macro and +//! adapter change rather than silently treating it as JSON. +//! +//! # OpenAPI 3.1 export +//! +//! With the `openapi` feature, `#[api(openapi)]` emits a `_openapi` function from the +//! same normalized operation model used by the HTTP adapters. The caller supplies title, version, +//! and an opaque source digest through [`openapi::OpenApiInfo`], then either serves +//! [`openapi::OpenApiDocument::to_json`] or writes exactly those canonical bytes with +//! [`openapi::OpenApiDocument::write_json`] from a CLI. Documents contain no server URLs, +//! environment names, timestamps, or other deployment topology. +//! +//! Every exposed wire type must implement [`openapi::OpenApiSchema`] as an explicit assertion that +//! its `schemars::JsonSchema` contract matches Serde serialization. The trait's naming hook is the +//! public component-name contract. Different schemas claiming one name, unsafe-width integer +//! schemas, and ambiguous non-null `anyOf`/untagged representations fail closed. Use bounded +//! integers (for example `u32`) for JSON numeric fields, and model optionality separately from +//! nullability through object `required` membership and nullable schemas. A normalized +//! `#[header("authorization")]` string (or optional string) is projected as an HTTP bearer +//! security scheme and operation requirement rather than as a raw header parameter. +//! +//! # Optional HTTP adapters +//! +//! `#[api(reqwest)]` generates `TraitNameClient` and enables a typed Reqwest client when this +//! crate's `reqwest` feature is enabled. `#[api(axum)]` generates `TraitNameAxum` plus one public +//! router per operation when the `axum` feature is enabled. `#[api(reqwest, axum)]` emits both +//! from the same normalized definition. A plain `#[api]` remains framework-independent and pulls +//! neither framework into DTO/contract-only builds. +//! +//! Generated clients use a fallible `try_new` constructor and builder. The builder validates an +//! absolute credential-free HTTP(S) base URL and configures request timeout and response byte +//! limit. A prevalidated [`reqwest::BaseUrl`] and policy-configured `reqwest::framework::Client` +//! may be injected instead. Request authorizers receive the final method, encoded path/query, and +//! exact serialized body bytes; credential headers and all body content are redacted from normal +//! diagnostics. +//! +//! Generated Axum adapters keep authentication, authorization, application state, body limits, +//! and timeout layers outside the adapter. Callers may layer the complete router or an individual +//! generated operation router before merging it into their application. + +extern crate self as api_macros; + +pub use api_macros_impl::api; + +#[cfg(feature = "axum")] +pub mod axum; +#[cfg(feature = "openapi")] +pub mod openapi; +#[cfg(feature = "reqwest")] +pub mod reqwest; + +/// A transport-neutral HTTP method. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum HttpMethod { + Get, + Post, + Put, + Patch, + Delete, + Head, + Options, +} + +/// Where an operation argument is encoded on the wire. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub enum ParameterLocation { + Path, + Query, + Header, + Body, +} + +/// Body encodings understood or explicitly reserved by the API contract. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[non_exhaustive] +pub enum WireKind { + Empty, + Json, + Streaming, + Multipart, + Binary, +} + +/// Stable metadata for one Rust method argument. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct ParameterMetadata { + pub rust_name: &'static str, + pub wire_name: &'static str, + pub location: ParameterLocation, +} + +/// Stable metadata for a request or response body. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct BodyMetadata { + pub wire_kind: WireKind, +} + +/// Stable metadata for one HTTP response. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct ResponseMetadata { + pub status: u16, + pub body: BodyMetadata, +} + +/// Complete transport metadata for one operation. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub struct OperationMetadata { + pub operation_id: &'static str, + pub method: HttpMethod, + pub path: &'static str, + pub parameters: &'static [ParameterMetadata], + pub request_body: BodyMetadata, + pub response: ResponseMetadata, + pub error_response: Option, +} + +/// Deterministic operation inventory emitted for an `#[api]` trait. +pub trait ApiContract { + const OPERATIONS: &'static [OperationMetadata]; +} + +/// Compile-time connection between operation metadata and its concrete body types. +pub trait Operation { + /// Tuple of all non-receiver parameter types in declaration order. + type Parameters; + /// JSON request body type, or [`NoBody`]. + type RequestBody; + /// JSON success response body type, or [`NoBody`]. + type ResponseBody; + /// JSON public error body type, or [`NoBody`]. + type ErrorBody; + + const METADATA: OperationMetadata; +} + +/// Public JSON error that can select its concrete HTTP status at the server boundary. +/// +/// `error_status` remains the contract's default/documented status. Implementations may return a +/// different 4xx/5xx status for one named error shape, allowing a generated client to preserve a +/// service's typed 401/403/404/409 responses without introducing duplicate route handlers. +pub trait HttpError { + fn status_code(&self) -> u16; +} + +/// Successful JSON response that selects one of an operation's declared success statuses. +/// +/// Operations opt in with `alternate_status`; ordinary one-status operations remain fully static. +pub trait HttpSuccess { + fn status_code(&self) -> u16; +} + +/// Public error type capable of normalizing Axum request-body rejections. +pub trait HttpRequestError: HttpError { + fn from_request_rejection(status: u16, message: String) -> Self; +} + +/// Marker used when an operation has no request, response, or public error body. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum NoBody {} diff --git a/crates/api-macros/src/openapi.rs b/crates/api-macros/src/openapi.rs new file mode 100644 index 000000000..4f5158289 --- /dev/null +++ b/crates/api-macros/src/openapi.rs @@ -0,0 +1,603 @@ +//! Deterministic OpenAPI 3.1 projection for `#[api(openapi)]` contracts. +//! +//! The projection deliberately has no deployment topology (`servers`) and no +//! generated timestamps. Callers supply an opaque source digest, which is +//! retained as provenance in `info.x-yoi-source-digest`. + +use std::{ + borrow::Cow, + collections::{BTreeMap, BTreeSet}, + fmt, fs, + path::Path, +}; + +pub use schemars::{self, JsonSchema}; +use serde_json::{Map, Value, json}; + +const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; + +/// An explicit assertion that `JsonSchema` describes the type's serialized +/// wire representation. +/// +/// OpenAPI-enabled APIs require this trait instead of accepting every +/// `JsonSchema` implementation implicitly. This makes unusual Serde behavior +/// (for example `flatten`, `untagged`, or a custom serializer) fail closed +/// until the type owner supplies and reviews a matching schema implementation. +/// The default component name delegates to [`JsonSchema::schema_name`]; types +/// may override it to provide a stable public wire name. +pub trait OpenApiSchema: JsonSchema { + /// Whether a parameter of this type must be present on the wire. + /// + /// This semantic hook intentionally lives on the resolved type, so aliases + /// such as `type Header = Option` retain optionality. + const PARAMETER_REQUIRED: bool = true; + /// Whether an `Authorization` header of this type is a bearer credential. + const BEARER_CREDENTIAL: bool = false; + + fn openapi_schema_name() -> Cow<'static, str> { + Self::schema_name() + } +} + +macro_rules! impl_openapi_schema { + ($($ty:ty),* $(,)?) => { + $(impl OpenApiSchema for $ty {})* + }; +} + +impl_openapi_schema!((), bool, char, i8, i16, i32, u8, u16, u32, f32, f64); + +impl OpenApiSchema for String { + const BEARER_CREDENTIAL: bool = true; +} + +impl OpenApiSchema for Option { + const PARAMETER_REQUIRED: bool = false; + const BEARER_CREDENTIAL: bool = T::BEARER_CREDENTIAL; +} +impl OpenApiSchema for Vec {} +impl OpenApiSchema for Box {} +impl OpenApiSchema for std::sync::Arc {} +impl OpenApiSchema for BTreeMap {} + +/// Stable, deployment-independent document identity supplied by an exporter. +#[derive(Debug, Clone, Copy)] +pub struct OpenApiInfo<'a> { + pub title: &'a str, + pub version: &'a str, + /// Opaque digest of the source revision used to build the contract. + pub source_digest: &'a str, +} + +/// An immutable OpenAPI 3.1 document with canonical JSON rendering. +#[derive(Debug, Clone, PartialEq)] +pub struct OpenApiDocument(Value); + +impl OpenApiDocument { + pub fn as_value(&self) -> &Value { + &self.0 + } + + /// Render pretty JSON with recursively sorted object keys and a final LF. + pub fn to_json(&self) -> Result { + let mut rendered = serde_json::to_string_pretty(&self.0)?; + rendered.push('\n'); + Ok(rendered) + } + + /// Export the same canonical bytes returned by [`Self::to_json`]. + pub fn write_json(&self, path: impl AsRef) -> Result<(), OpenApiError> { + fs::write(path, self.to_json()?)?; + Ok(()) + } +} + +#[derive(Debug)] +pub enum OpenApiError { + InvalidContract(String), + Io(std::io::Error), + Json(serde_json::Error), +} + +impl fmt::Display for OpenApiError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidContract(message) => formatter.write_str(message), + Self::Io(error) => write!(formatter, "failed to export OpenAPI document: {error}"), + Self::Json(error) => write!(formatter, "failed to serialize OpenAPI document: {error}"), + } + } +} + +impl std::error::Error for OpenApiError {} + +impl From for OpenApiError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +impl From for OpenApiError { + fn from(error: serde_json::Error) -> Self { + Self::Json(error) + } +} + +/// Builder used by code generated for an `#[api(openapi)]` trait. +#[doc(hidden)] +pub struct OpenApiBuilder { + info: Value, + paths: BTreeMap>, + schemas: BTreeMap, + operation_ids: BTreeSet, + uses_bearer_auth: bool, + uses_browser_auth: bool, +} + +impl OpenApiBuilder { + pub fn new(info: OpenApiInfo<'_>) -> Result { + for (field, value) in [ + ("title", info.title), + ("version", info.version), + ("source digest", info.source_digest), + ] { + if value.trim().is_empty() { + return Err(OpenApiError::InvalidContract(format!( + "OpenAPI {field} must not be empty" + ))); + } + } + + Ok(Self { + info: json!({ + "title": info.title, + "version": info.version, + "x-yoi-source-digest": info.source_digest, + }), + paths: BTreeMap::new(), + schemas: BTreeMap::new(), + operation_ids: BTreeSet::new(), + uses_bearer_auth: false, + uses_browser_auth: false, + }) + } + + pub fn operation<'a>( + &'a mut self, + method: &'static str, + path: &'static str, + operation_id: &'static str, + ) -> Result, OpenApiError> { + let method = method.to_ascii_lowercase(); + if !matches!( + method.as_str(), + "get" | "post" | "put" | "patch" | "delete" | "head" | "options" | "trace" + ) { + return Err(OpenApiError::InvalidContract(format!( + "unsupported OpenAPI method `{method}` for `{operation_id}`" + ))); + } + if !self.operation_ids.insert(operation_id.to_owned()) { + return Err(OpenApiError::InvalidContract(format!( + "duplicate operationId `{operation_id}`" + ))); + } + if self + .paths + .get(path) + .is_some_and(|operations| operations.contains_key(&method)) + { + return Err(OpenApiError::InvalidContract(format!( + "duplicate operation for {method} {path}" + ))); + } + + Ok(OpenApiOperation { + parent: self, + method, + path: path.to_owned(), + operation_id, + parameters: Vec::new(), + request_body: None, + responses: BTreeMap::new(), + bearer_security_required: None, + browser_security_required: false, + }) + } + + pub fn finish(self) -> Result { + let paths = self + .paths + .into_iter() + .map(|(path, operations)| { + let operations = operations.into_iter().collect::>(); + (path, Value::Object(operations)) + }) + .collect::>(); + let schemas = self.schemas.into_iter().collect::>(); + let mut components = Map::new(); + components.insert("schemas".to_owned(), Value::Object(schemas)); + let mut security_schemes = Map::new(); + if self.uses_bearer_auth { + security_schemes.insert( + "bearerAuth".to_owned(), + json!({ + "type": "http", + "scheme": "bearer" + }), + ); + } + if self.uses_browser_auth { + security_schemes.insert( + "browserSession".to_owned(), + json!({ + "type": "apiKey", + "in": "cookie", + "name": "yoi_workspace_session" + }), + ); + } + if !security_schemes.is_empty() { + components.insert( + "securitySchemes".to_owned(), + Value::Object(security_schemes), + ); + } + + let document = canonicalize(json!({ + "openapi": "3.1.0", + "info": self.info, + "paths": paths, + "components": components, + })); + validate_component_references(&document)?; + Ok(OpenApiDocument(document)) + } + + fn schema_ref(&mut self) -> Result { + let name = T::openapi_schema_name().into_owned(); + validate_component_name(&name)?; + + let mut schema = serde_json::to_value(schemars::schema_for!(T))?; + let definitions = schema + .as_object_mut() + .and_then(|object| object.remove("$defs")) + .unwrap_or_else(|| json!({})); + if let Some(object) = schema.as_object_mut() { + object.remove("$schema"); + object.remove("title"); + } + rewrite_definition_refs(&mut schema); + validate_schema(&schema, &name)?; + self.insert_schema(name.clone(), canonicalize(schema))?; + + let definitions = definitions.as_object().ok_or_else(|| { + OpenApiError::InvalidContract(format!( + "schema `{name}` emitted non-object JSON Schema definitions" + )) + })?; + for (definition_name, definition) in definitions { + validate_component_name(definition_name)?; + let mut definition = definition.clone(); + rewrite_definition_refs(&mut definition); + validate_schema(&definition, definition_name)?; + self.insert_schema(definition_name.clone(), canonicalize(definition))?; + } + + Ok(json!({ "$ref": format!("#/components/schemas/{name}") })) + } + + fn insert_schema(&mut self, name: String, schema: Value) -> Result<(), OpenApiError> { + match self.schemas.get(&name) { + Some(existing) if existing != &schema => Err(OpenApiError::InvalidContract(format!( + "OpenAPI component name collision for `{name}`" + ))), + Some(_) => Ok(()), + None => { + self.schemas.insert(name, schema); + Ok(()) + } + } + } +} + +/// One operation under construction. Generated code adds its complete wire +/// metadata before atomically inserting it into the parent document. +#[doc(hidden)] +pub struct OpenApiOperation<'a> { + parent: &'a mut OpenApiBuilder, + method: String, + path: String, + operation_id: &'static str, + parameters: Vec, + request_body: Option, + responses: BTreeMap, + bearer_security_required: Option, + browser_security_required: bool, +} + +impl OpenApiOperation<'_> { + pub fn parameter( + &mut self, + name: &'static str, + location: &'static str, + ) -> Result<(), OpenApiError> { + if !matches!(location, "path" | "query" | "header") { + return Err(OpenApiError::InvalidContract(format!( + "unsupported parameter location `{location}` for `{}`", + self.operation_id + ))); + } + let required = location == "path" || T::PARAMETER_REQUIRED; + if location == "header" && name.eq_ignore_ascii_case("authorization") { + if !T::BEARER_CREDENTIAL { + return Err(OpenApiError::InvalidContract(format!( + "Authorization header for `{}` must use a bearer credential schema", + self.operation_id + ))); + } + if self.bearer_security_required.replace(required).is_some() { + return Err(OpenApiError::InvalidContract(format!( + "multiple Authorization headers for `{}`", + self.operation_id + ))); + } + self.parent.uses_bearer_auth = true; + return Ok(()); + } + let schema = self.parent.schema_ref::()?; + self.parameters.push(json!({ + "name": name, + "in": location, + "required": required, + "schema": schema, + "style": if location == "query" { "form" } else { "simple" }, + "explode": location == "query", + })); + Ok(()) + } + + pub fn bearer_authentication(&mut self) { + self.parent.uses_bearer_auth = true; + self.bearer_security_required = Some(true); + } + + pub fn browser_authentication(&mut self) { + self.parent.uses_browser_auth = true; + self.browser_security_required = true; + } + + pub fn request_body( + &mut self, + content_type: &'static str, + ) -> Result<(), OpenApiError> { + if self.request_body.is_some() { + return Err(OpenApiError::InvalidContract(format!( + "multiple request bodies for `{}`", + self.operation_id + ))); + } + let schema = self.parent.schema_ref::()?; + self.request_body = Some(json!({ + "required": true, + "content": { content_type: { "schema": schema } }, + })); + Ok(()) + } + + pub fn response( + &mut self, + status: u16, + content_type: &'static str, + description: &'static str, + ) -> Result<(), OpenApiError> { + let schema = self.parent.schema_ref::()?; + self.insert_response( + status, + json!({ + "description": description, + "content": { content_type: { "schema": schema } }, + }), + ) + } + + pub fn empty_response( + &mut self, + status: u16, + description: &'static str, + ) -> Result<(), OpenApiError> { + self.insert_response(status, json!({ "description": description })) + } + + fn insert_response(&mut self, status: u16, response: Value) -> Result<(), OpenApiError> { + if !(100..=599).contains(&status) { + return Err(OpenApiError::InvalidContract(format!( + "invalid HTTP response status `{status}` for `{}`", + self.operation_id + ))); + } + let status = status.to_string(); + match self.responses.get(&status) { + Some(existing) if existing != &response => Err(OpenApiError::InvalidContract(format!( + "conflicting response status `{status}` for `{}`", + self.operation_id + ))), + Some(_) => Ok(()), + None => { + self.responses.insert(status, response); + Ok(()) + } + } + } + + pub fn finish(self) -> Result<(), OpenApiError> { + if self.responses.is_empty() { + return Err(OpenApiError::InvalidContract(format!( + "operation `{}` has no responses", + self.operation_id + ))); + } + let responses = self.responses.into_iter().collect::>(); + let mut operation = Map::new(); + operation.insert("operationId".to_owned(), json!(self.operation_id)); + operation.insert("responses".to_owned(), Value::Object(responses)); + if !self.parameters.is_empty() { + operation.insert("parameters".to_owned(), Value::Array(self.parameters)); + } + if let Some(request_body) = self.request_body { + operation.insert("requestBody".to_owned(), request_body); + } + let mut security = Vec::new(); + if self.bearer_security_required == Some(false) { + security.push(json!({})); + } + if self.bearer_security_required.is_some() { + security.push(json!({ "bearerAuth": [] })); + } + if self.browser_security_required { + security.push(json!({ "browserSession": [] })); + } + if !security.is_empty() { + operation.insert("security".to_owned(), Value::Array(security)); + } + self.parent + .paths + .entry(self.path) + .or_default() + .insert(self.method, Value::Object(operation)); + Ok(()) + } +} + +fn validate_component_name(name: &str) -> Result<(), OpenApiError> { + if name.is_empty() + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err(OpenApiError::InvalidContract(format!( + "OpenAPI component name `{name}` must match [A-Za-z0-9._-]+" + ))); + } + Ok(()) +} + +fn validate_component_references(document: &Value) -> Result<(), OpenApiError> { + let schemas = document["components"]["schemas"] + .as_object() + .ok_or_else(|| OpenApiError::InvalidContract("missing component schema map".to_owned()))?; + let mut references = Vec::new(); + collect_references(document, &mut references); + for reference in references { + let Some(name) = reference.strip_prefix("#/components/schemas/") else { + return Err(OpenApiError::InvalidContract(format!( + "unsupported non-component schema reference `{reference}`" + ))); + }; + if !schemas.contains_key(name) { + return Err(OpenApiError::InvalidContract(format!( + "unresolved OpenAPI component reference `{reference}`" + ))); + } + } + Ok(()) +} + +fn collect_references<'a>(value: &'a Value, references: &mut Vec<&'a str>) { + match value { + Value::Object(object) => { + if let Some(reference) = object.get("$ref").and_then(Value::as_str) { + references.push(reference); + } + for value in object.values() { + collect_references(value, references); + } + } + Value::Array(values) => { + for value in values { + collect_references(value, references); + } + } + _ => {} + } +} + +fn rewrite_definition_refs(value: &mut Value) { + match value { + Value::Object(object) => { + if let Some(Value::String(reference)) = object.get_mut("$ref") { + if let Some(name) = reference.strip_prefix("#/$defs/") { + *reference = format!("#/components/schemas/{name}"); + } + } + for value in object.values_mut() { + rewrite_definition_refs(value); + } + } + Value::Array(values) => values.iter_mut().for_each(rewrite_definition_refs), + _ => {} + } +} + +fn validate_schema(value: &Value, component: &str) -> Result<(), OpenApiError> { + match value { + Value::Object(object) => { + if object.get("type").and_then(Value::as_str) == Some("integer") { + let safe_format = matches!( + object.get("format").and_then(Value::as_str), + Some("int8" | "uint8" | "int16" | "uint16" | "int32" | "uint32") + ); + let minimum = object.get("minimum").and_then(Value::as_f64); + let maximum = object.get("maximum").and_then(Value::as_f64); + if !safe_format + && (minimum.is_none_or(|value| value < -(MAX_SAFE_INTEGER as f64)) + || maximum.is_none_or(|value| value > MAX_SAFE_INTEGER as f64)) + { + return Err(OpenApiError::InvalidContract(format!( + "integer schema in `{component}` exceeds the JSON safe-integer range; expose a bounded wire integer" + ))); + } + } + // A non-null anyOf is commonly emitted for Serde's untagged enum + // representation. Its branch-selection semantics are not lossless + // enough for this projection. Nullable schemas remain supported. + if let Some(Value::Array(branches)) = object.get("anyOf") { + let null_branches = branches + .iter() + .filter(|branch| branch.get("type").and_then(Value::as_str) == Some("null")) + .count(); + if null_branches != 1 || branches.len() != 2 { + return Err(OpenApiError::InvalidContract(format!( + "schema `{component}` uses unsupported ambiguous anyOf/untagged semantics" + ))); + } + } + for value in object.values() { + validate_schema(value, component)?; + } + } + Value::Array(values) => { + for value in values { + validate_schema(value, component)?; + } + } + _ => {} + } + Ok(()) +} + +fn canonicalize(value: Value) -> Value { + match value { + Value::Object(object) => { + let sorted = object + .into_iter() + .map(|(key, value)| (key, canonicalize(value))) + .collect::>() + .into_iter() + .collect(); + Value::Object(sorted) + } + Value::Array(values) => Value::Array(values.into_iter().map(canonicalize).collect()), + scalar => scalar, + } +} diff --git a/crates/api-macros/src/reqwest.rs b/crates/api-macros/src/reqwest.rs new file mode 100644 index 000000000..dd54b58d6 --- /dev/null +++ b/crates/api-macros/src/reqwest.rs @@ -0,0 +1,571 @@ +//! Reqwest transport support used by clients generated with `#[api(reqwest)]`. +//! +//! The generated builder validates the base URL before it can be stored. Callers that enforce +//! stricter egress policy (custom DNS resolution, proxy policy, TLS roots, or connection pinning) +//! can construct their own [`reqwest::Client`] and inject it with the validated [`BaseUrl`]. + +use std::{fmt, time::Duration}; + +use futures::StreamExt as _; +use reqwest::{ + Client, Method, StatusCode, Url, + header::{HeaderMap, HeaderName, HeaderValue}, +}; +use serde::{Serialize, de::DeserializeOwned}; + +/// Default timeout applied to the complete request, including response-body reads. +pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +/// Default maximum response body size (1 MiB). +pub const DEFAULT_RESPONSE_BODY_LIMIT: usize = 1024 * 1024; + +/// A parsed HTTP(S) base URL without credentials, query, or fragment. +#[derive(Clone, Eq, PartialEq)] +pub struct BaseUrl(Url); + +impl BaseUrl { + /// Access the validated URL. + pub fn as_url(&self) -> &Url { + &self.0 + } +} + +impl fmt::Debug for BaseUrl { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_tuple("BaseUrl").field(&self.0).finish() + } +} + +impl TryFrom<&str> for BaseUrl { + type Error = ClientBuildError; + + fn try_from(value: &str) -> Result { + Url::parse(value) + .map_err(|_| ClientBuildError::InvalidBaseUrl) + .and_then(Self::try_from) + } +} + +impl TryFrom for BaseUrl { + type Error = ClientBuildError; + + fn try_from(value: String) -> Result { + Self::try_from(value.as_str()) + } +} + +impl TryFrom for BaseUrl { + type Error = ClientBuildError; + + fn try_from(mut value: Url) -> Result { + if !matches!(value.scheme(), "http" | "https") + || value.cannot_be_a_base() + || value.host_str().is_none() + || !value.username().is_empty() + || value.password().is_some() + || value.query().is_some() + || value.fragment().is_some() + { + return Err(ClientBuildError::InvalidBaseUrl); + } + if !value.path().ends_with('/') { + let mut path = value.path().to_owned(); + path.push('/'); + value.set_path(&path); + } + Ok(Self(value)) + } +} + +/// Failure while constructing a generated client. +#[derive(Clone, Copy, Eq, PartialEq)] +pub enum ClientBuildError { + /// The base URL was not an absolute credential-free HTTP(S) URL without query or fragment. + InvalidBaseUrl, + /// Reqwest could not construct its default client. + ClientConstruction, +} + +impl fmt::Debug for ClientBuildError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, formatter) + } +} + +impl fmt::Display for ClientBuildError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::InvalidBaseUrl => "invalid HTTP client base URL", + Self::ClientConstruction => "failed to construct HTTP client", + }) + } +} + +impl std::error::Error for ClientBuildError {} + +/// Exact canonical request material supplied to a [`RequestAuthorizer`]. +/// +/// `body` is the same byte slice subsequently passed to Reqwest. `path_and_query` is taken from +/// the final URL after path-segment and query encoding. +pub struct AuthorizerRequest<'a> { + pub method: &'a Method, + pub path_and_query: &'a str, + pub body: &'a [u8], +} + +impl fmt::Debug for AuthorizerRequest<'_> { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AuthorizerRequest") + .field("method", self.method) + .field("path_and_query", &"") + .field("body", &"") + .finish() + } +} + +/// Opaque authorizer failure. Secret-bearing authorizer errors are intentionally not retained. +#[derive(Clone, Copy, Default, Eq, PartialEq)] +pub struct AuthorizationError(()); + +impl AuthorizationError { + pub const fn new() -> Self { + Self(()) + } +} + +impl fmt::Debug for AuthorizationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("AuthorizationError") + } +} + +impl fmt::Display for AuthorizationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("request authorization failed") + } +} + +impl std::error::Error for AuthorizationError {} + +/// Supplies credential headers for an exact encoded request. +pub trait RequestAuthorizer: Send + Sync { + fn authorize(&self, request: AuthorizerRequest<'_>) -> Result; +} + +/// Authorizer used by default; it adds no headers. +#[derive(Clone, Copy, Default)] +pub struct NoAuthorizer; + +impl RequestAuthorizer for NoAuthorizer { + fn authorize(&self, _request: AuthorizerRequest<'_>) -> Result { + Ok(HeaderMap::new()) + } +} + +impl fmt::Debug for NoAuthorizer { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("NoAuthorizer") + } +} + +/// Builder shared by generated API-specific client builders. +pub struct ClientBuilder { + base_url: BaseUrl, + client: Option, + authorizer: A, + request_timeout: Duration, + response_body_limit: usize, +} + +impl ClientBuilder { + pub fn try_new(base_url: impl AsRef) -> Result { + Ok(Self::from_base_url(BaseUrl::try_from(base_url.as_ref())?)) + } + + pub fn from_base_url(base_url: BaseUrl) -> Self { + Self { + base_url, + client: None, + authorizer: NoAuthorizer, + request_timeout: DEFAULT_REQUEST_TIMEOUT, + response_body_limit: DEFAULT_RESPONSE_BODY_LIMIT, + } + } +} + +impl ClientBuilder { + pub fn client(mut self, client: Client) -> Self { + self.client = Some(client); + self + } + + pub fn authorizer(self, authorizer: B) -> ClientBuilder { + ClientBuilder { + base_url: self.base_url, + client: self.client, + authorizer, + request_timeout: self.request_timeout, + response_body_limit: self.response_body_limit, + } + } + + pub fn request_timeout(mut self, timeout: Duration) -> Self { + self.request_timeout = timeout; + self + } + + pub fn response_body_limit(mut self, limit: usize) -> Self { + self.response_body_limit = limit; + self + } + + pub fn build(self) -> Result, ClientBuildError> { + let client = match self.client { + Some(client) => client, + None => Client::builder() + .build() + .map_err(|_| ClientBuildError::ClientConstruction)?, + }; + Ok(ClientCore { + base_url: self.base_url, + client, + authorizer: self.authorizer, + request_timeout: self.request_timeout, + response_body_limit: self.response_body_limit, + }) + } +} + +impl fmt::Debug for ClientBuilder { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientBuilder") + .field("base_url", &self.base_url) + .field("client", &"") + .field("authorizer", &"") + .field("request_timeout", &self.request_timeout) + .field("response_body_limit", &self.response_body_limit) + .finish() + } +} + +/// Framework support held by each generated API client. +pub struct ClientCore { + base_url: BaseUrl, + client: Client, + authorizer: A, + request_timeout: Duration, + response_body_limit: usize, +} + +impl Clone for ClientCore { + fn clone(&self) -> Self { + Self { + base_url: self.base_url.clone(), + client: self.client.clone(), + authorizer: self.authorizer.clone(), + request_timeout: self.request_timeout, + response_body_limit: self.response_body_limit, + } + } +} + +impl fmt::Debug for ClientCore { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ClientCore") + .field("base_url", &self.base_url) + .field("client", &"") + .field("authorizer", &"") + .field("request_timeout", &self.request_timeout) + .field("response_body_limit", &self.response_body_limit) + .finish() + } +} + +impl ClientCore { + pub fn from_parts( + base_url: BaseUrl, + client: Client, + authorizer: A, + request_timeout: Duration, + response_body_limit: usize, + ) -> Self { + Self { + base_url, + client, + authorizer, + request_timeout, + response_body_limit, + } + } + + /// Build an endpoint by appending already-separated, unencoded path segments. + pub fn endpoint(&self, segments: &[String]) -> Result { + let mut url = self.base_url.0.clone(); + let mut path = url + .path_segments_mut() + .map_err(|_| ClientFailure::RequestEncoding)?; + path.pop_if_empty(); + for segment in segments { + path.push(segment); + } + drop(path); + Ok(url) + } + + pub fn response_body_limit(&self) -> usize { + self.response_body_limit + } +} + +impl ClientCore { + pub async fn send( + &self, + method: Method, + url: Url, + mut headers: HeaderMap, + body: Option>, + ) -> Result { + let body_bytes = body.as_deref().unwrap_or_default(); + let path_and_query = &url[url::Position::BeforePath..url::Position::AfterQuery]; + let authorization = self + .authorizer + .authorize(AuthorizerRequest { + method: &method, + path_and_query, + body: body_bytes, + }) + .map_err(|_| ClientFailure::Authorization)?; + headers.extend(authorization); + + let mut request = self + .client + .request(method, url) + .headers(headers) + .timeout(self.request_timeout); + if let Some(body) = body { + request = request + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body); + } + let response = request.send().await.map_err(|error| { + if error.is_timeout() { + ClientFailure::Timeout + } else if error.is_connect() { + ClientFailure::Connect + } else { + ClientFailure::Transport + } + })?; + let status = response.status(); + let mut stream = response.bytes_stream(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|error| { + if error.is_timeout() { + ClientFailure::Timeout + } else { + ClientFailure::Transport + } + })?; + let Some(size) = bytes.len().checked_add(chunk.len()) else { + return Err(ClientFailure::ResponseTooLarge { + limit: self.response_body_limit, + }); + }; + if size > self.response_body_limit { + return Err(ClientFailure::ResponseTooLarge { + limit: self.response_body_limit, + }); + } + bytes.extend_from_slice(&chunk); + } + Ok(ReceivedResponse { status, bytes }) + } +} + +/// Encode a serializable query DTO and append its pairs without changing existing pairs. +pub fn append_query(url: &mut Url, value: &T) -> Result<(), ClientFailure> { + let encoded = serde_urlencoded::to_string(value).map_err(|_| ClientFailure::RequestEncoding)?; + if encoded.is_empty() { + return Ok(()); + } + let combined = match url.query() { + Some(existing) if !existing.is_empty() => format!("{existing}&{encoded}"), + _ => encoded, + }; + url.set_query(Some(&combined)); + Ok(()) +} + +/// Insert a string-form header value. +pub fn insert_header( + headers: &mut HeaderMap, + name: &str, + value: &T, +) -> Result<(), ClientFailure> { + let name = + HeaderName::from_bytes(name.as_bytes()).map_err(|_| ClientFailure::RequestEncoding)?; + let value = + HeaderValue::from_str(&value.to_string()).map_err(|_| ClientFailure::RequestEncoding)?; + headers.insert(name, value); + Ok(()) +} + +/// Serialize the request body once. These exact bytes are authorized and transmitted. +pub fn encode_json(value: &T) -> Result, ClientFailure> { + serde_json::to_vec(value).map_err(|_| ClientFailure::RequestEncoding) +} + +/// Response received under the configured byte limit. Its content is always redacted in Debug. +pub struct ReceivedResponse { + status: StatusCode, + bytes: Vec, +} + +impl ReceivedResponse { + pub fn status(&self) -> StatusCode { + self.status + } + + pub fn decode_json(&self, kind: DecodeKind) -> Result { + serde_json::from_slice(&self.bytes).map_err(|_| ClientFailure::Decode { kind }) + } + + pub fn require_empty(&self) -> Result<(), ClientFailure> { + if self.bytes.is_empty() { + Ok(()) + } else { + Err(ClientFailure::UnexpectedBody) + } + } +} + +impl fmt::Debug for ReceivedResponse { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ReceivedResponse") + .field("status", &self.status) + .field("bytes", &"") + .finish() + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum DecodeKind { + Success, + PublicError, +} + +/// Non-public-error failure from a generated client. +#[derive(Clone, Copy, Eq, PartialEq)] +pub enum ClientFailure { + Authorization, + RequestEncoding, + Connect, + Transport, + Timeout, + ResponseTooLarge { limit: usize }, + UnexpectedStatus { expected: u16, actual: u16 }, + ErrorResponseDecode { status: u16 }, + Decode { kind: DecodeKind }, + UnexpectedBody, +} + +impl fmt::Debug for ClientFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, formatter) + } +} + +impl fmt::Display for ClientFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Authorization => formatter.write_str("request authorization failed"), + Self::RequestEncoding => formatter.write_str("request encoding failed"), + Self::Connect => formatter.write_str("HTTP connection failed"), + Self::Transport => formatter.write_str("HTTP transport failed"), + Self::Timeout => formatter.write_str("HTTP request timed out"), + Self::ResponseTooLarge { limit } => { + write!(formatter, "response body exceeded {limit} bytes") + } + Self::UnexpectedStatus { expected, actual } => { + write!( + formatter, + "expected HTTP status {expected}, received {actual}" + ) + } + Self::ErrorResponseDecode { status } => { + write!(formatter, "HTTP {status} error response JSON was malformed") + } + Self::Decode { + kind: DecodeKind::Success, + } => formatter.write_str("success response JSON was malformed"), + Self::Decode { + kind: DecodeKind::PublicError, + } => formatter.write_str("public error response JSON was malformed"), + Self::UnexpectedBody => formatter.write_str("empty response contained a body"), + } + } +} + +impl std::error::Error for ClientFailure {} + +/// Typed error returned by generated client methods. +pub enum ClientError { + Failure(ClientFailure), + Public { status: StatusCode, error: E }, +} + +impl ClientError { + pub fn failure(failure: ClientFailure) -> Self { + Self::Failure(failure) + } + + pub fn public(status: StatusCode, error: E) -> Self { + Self::Public { status, error } + } + + pub fn into_public_error(self) -> Option { + match self { + Self::Public { error, .. } => Some(error), + Self::Failure(_) => None, + } + } +} + +impl From for ClientError { + fn from(value: ClientFailure) -> Self { + Self::Failure(value) + } +} + +impl fmt::Debug for ClientError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Failure(failure) => formatter.debug_tuple("Failure").field(failure).finish(), + Self::Public { status, .. } => formatter + .debug_struct("Public") + .field("status", status) + .field("error", &"") + .finish(), + } + } +} + +impl fmt::Display for ClientError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Failure(failure) => fmt::Display::fmt(failure, formatter), + Self::Public { status, .. } => write!(formatter, "public HTTP error {status}"), + } + } +} + +impl std::error::Error for ClientError {} + +/// Reexports used by generated code and advanced client construction. +pub mod framework { + pub use reqwest::{Client, Method, StatusCode, Url, header}; +} diff --git a/crates/api-macros/tests/compile_fail.rs b/crates/api-macros/tests/compile_fail.rs new file mode 100644 index 000000000..5858355df --- /dev/null +++ b/crates/api-macros/tests/compile_fail.rs @@ -0,0 +1,5 @@ +#[test] +fn invalid_api_declarations_have_stable_diagnostics() { + let tests = trybuild::TestCases::new(); + tests.compile_fail("tests/ui/*.rs"); +} diff --git a/crates/api-macros/tests/expansion.rs b/crates/api-macros/tests/expansion.rs new file mode 100644 index 000000000..a52ae4a78 --- /dev/null +++ b/crates/api-macros/tests/expansion.rs @@ -0,0 +1,109 @@ +#![allow(async_fn_in_trait)] + +use api_macros::{ApiContract, HttpMethod, NoBody, Operation, ParameterLocation, WireKind, api}; + +pub struct CreateWidget; +pub struct Widget; +pub struct Lookup; +pub struct PublicError; +pub struct SearchResult(std::marker::PhantomData); + +#[api] +pub trait WidgetApi { + #[post( + "/widgets", + operation_id = "widgets.create", + status = 201, + error_status = 422 + )] + async fn create( + &self, + #[body] request: CreateWidget, + #[header("x-request-id")] request_id: String, + ) -> Result; + + #[get("/widgets/{widget_id}", operation_id = "widgets.get")] + async fn lookup(&self, widget_id: u64, #[query] query: Lookup) -> Result; + + #[delete("/widgets/{widget_id}", operation_id = "widgets.delete")] + async fn delete(&self, #[path] widget_id: u64) -> (); +} + +#[api] +pub trait WrapperApi { + #[get("/search", operation_id = "search")] + async fn search(&self) -> SearchResult; +} + +#[api] +pub trait RawIdentifierApi { + #[get("/type", operation_id = "raw.type")] + async fn r#type(&self) -> Widget; +} + +#[api] +pub trait UnusualIdentifierApi { + #[get("/underscores", operation_id = "unusual.underscores")] + async fn __(&self) -> Widget; + + #[get("/underscore-digit", operation_id = "unusual.underscore_digit")] + async fn _0(&self) -> Widget; +} + +fn assert_operation_types() +where + O: Operation< + Parameters = (CreateWidget, String), + RequestBody = CreateWidget, + ResponseBody = Widget, + ErrorBody = PublicError, + >, +{ +} + +#[test] +fn expansion_exposes_deterministic_metadata_and_type_connections() { + assert_operation_types::(); + + fn assert_named_wrapper>>() {} + assert_named_wrapper::(); + let raw = ::METADATA; + assert_eq!(raw.operation_id, "raw.type"); + let underscores = ::METADATA; + assert_eq!(underscores.operation_id, "unusual.underscores"); + let underscore_digit = + ::METADATA; + assert_eq!(underscore_digit.operation_id, "unusual.underscore_digit"); + + let operations = ::OPERATIONS; + assert_eq!(operations.len(), 3); + assert_eq!(operations[0].operation_id, "widgets.create"); + assert_eq!(operations[1].operation_id, "widgets.delete"); + assert_eq!(operations[2].operation_id, "widgets.get"); + + let create = ::METADATA; + assert_eq!(operations[0], create); + assert_eq!(create.method, HttpMethod::Post); + assert_eq!(create.path, "/widgets"); + assert_eq!(create.request_body.wire_kind, WireKind::Json); + assert_eq!(create.response.status, 201); + assert_eq!(create.response.body.wire_kind, WireKind::Json); + assert_eq!(create.error_response.unwrap().status, 422); + assert_eq!(create.parameters[0].location, ParameterLocation::Body); + assert_eq!(create.parameters[1].location, ParameterLocation::Header); + assert_eq!(create.parameters[1].wire_name, "x-request-id"); + + let lookup = ::METADATA; + assert_eq!(lookup.parameters[0].location, ParameterLocation::Path); + assert_eq!(lookup.parameters[1].location, ParameterLocation::Query); + + let delete = ::METADATA; + assert_eq!(delete.response.status, 204); + assert_eq!(delete.response.body.wire_kind, WireKind::Empty); + assert_eq!(delete.error_response, None); + + fn assert_empty_request>() {} + fn assert_empty_response>() {} + assert_empty_request::(); + assert_empty_response::(); +} diff --git a/crates/api-macros/tests/http_adapters.rs b/crates/api-macros/tests/http_adapters.rs new file mode 100644 index 000000000..efc9d3a68 --- /dev/null +++ b/crates/api-macros/tests/http_adapters.rs @@ -0,0 +1,292 @@ +#![cfg(all(feature = "reqwest", feature = "axum"))] + +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; + +use api_macros::{api, reqwest as api_reqwest}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CreateWidget { + name: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Lookup { + mode: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct Widget { + id: u64, + name: String, + mode: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct PublicError { + message: String, + #[serde(skip)] + status: u16, +} + +impl api_macros::HttpError for PublicError { + fn status_code(&self) -> u16 { + self.status + } +} + +#[api(reqwest, axum)] +pub trait WidgetApi { + #[post( + "/widgets/{widget_id}", + operation_id = "widgets.create", + status = 201, + error_status = 422 + )] + async fn create( + &self, + #[path] widget_id: u64, + #[query] lookup: Lookup, + #[header("authorization")] authorization: String, + #[body] request: CreateWidget, + ) -> Result; + + #[delete("/widgets/{widget_id}", operation_id = "widgets.delete", status = 204)] + async fn delete(&self, #[path] widget_id: u64) -> (); +} + +#[derive(Clone)] +pub struct WidgetService; + +impl WidgetApi for WidgetService { + async fn create( + &self, + widget_id: u64, + lookup: Lookup, + authorization: String, + request: CreateWidget, + ) -> Result { + assert_eq!(authorization, "Bearer top-secret"); + if request.name == "bad" { + return Err(PublicError { + message: "sensitive-public-detail".to_owned(), + status: 409, + }); + } + Ok(Widget { + id: widget_id, + name: request.name, + mode: lookup.mode, + }) + } + + async fn delete(&self, _widget_id: u64) {} +} + +#[api(reqwest)] +pub trait FailureApi { + #[get("/malformed", operation_id = "failure.malformed", status = 200)] + async fn malformed(&self) -> Widget; + + #[get("/oversized", operation_id = "failure.oversized", status = 200)] + async fn oversized(&self) -> Widget; + + #[get("/slow", operation_id = "failure.slow", status = 200)] + async fn slow(&self) -> Widget; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AuthorizedRequest { + method: String, + path_and_query: String, + body: Vec, +} + +#[derive(Clone)] +pub struct RecordingAuthorizer { + request: Arc>>, + credential: String, +} + +impl api_reqwest::RequestAuthorizer for RecordingAuthorizer { + fn authorize( + &self, + request: api_reqwest::AuthorizerRequest<'_>, + ) -> Result { + *self.request.lock().expect("recording lock") = Some(AuthorizedRequest { + method: request.method.to_string(), + path_and_query: request.path_and_query.to_owned(), + body: request.body.to_vec(), + }); + let mut headers = api_reqwest::framework::header::HeaderMap::new(); + headers.insert( + api_reqwest::framework::header::AUTHORIZATION, + self.credential.parse().expect("valid fixture credential"), + ); + Ok(headers) + } +} + +async fn malformed() -> &'static str { + "this is not json" +} + +async fn oversized() -> String { + "response-content-that-must-never-appear-in-debug-output".to_owned() +} + +async fn slow() -> &'static str { + tokio::time::sleep(Duration::from_millis(100)).await; + "{}" +} + +#[tokio::test] +async fn generated_client_and_router_follow_the_normalized_contract() { + let app = WidgetApiAxum::router(WidgetService).merge( + api_macros::axum::framework::Router::new() + .route("/malformed", api_macros::axum::framework::get(malformed)) + .route("/oversized", api_macros::axum::framework::get(oversized)) + .route("/slow", api_macros::axum::framework::get(slow)), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind fixture server"); + let address = listener.local_addr().expect("fixture address"); + let server = tokio::spawn(async move { + api_macros::axum::framework::serve(listener, app) + .await + .expect("serve fixture API"); + }); + let base_url = format!("http://{address}/"); + + let recorded = Arc::new(Mutex::new(None)); + let client = WidgetApiClient::builder(&base_url) + .expect("validated base URL") + .authorizer(RecordingAuthorizer { + request: recorded.clone(), + credential: "Bearer top-secret".to_owned(), + }) + .build() + .expect("client build"); + + let widget = client + .create( + 7, + Lookup { + mode: "full".to_owned(), + }, + "placeholder-overwritten-by-authorizer".to_owned(), + CreateWidget { + name: "alpha".to_owned(), + }, + ) + .await + .expect("typed success response"); + assert_eq!( + widget, + Widget { + id: 7, + name: "alpha".to_owned(), + mode: "full".to_owned(), + } + ); + assert_eq!( + recorded.lock().expect("recording lock").clone(), + Some(AuthorizedRequest { + method: "POST".to_owned(), + path_and_query: "/widgets/7?mode=full".to_owned(), + body: br#"{"name":"alpha"}"#.to_vec(), + }) + ); + assert!(!format!("{client:?}").contains("top-secret")); + + client.delete(7).await.expect("typed empty response"); + + let public_error = client + .create( + 8, + Lookup { + mode: "full".to_owned(), + }, + "placeholder".to_owned(), + CreateWidget { + name: "bad".to_owned(), + }, + ) + .await + .expect_err("typed public error"); + assert!(!format!("{public_error:?}").contains("sensitive-public-detail")); + assert!(matches!( + &public_error, + api_reqwest::ClientError::Public { status, .. } + if *status == api_reqwest::framework::StatusCode::CONFLICT + )); + assert_eq!( + public_error.into_public_error(), + Some(PublicError { + message: "sensitive-public-detail".to_owned(), + status: 0, + }) + ); + + let malformed_error = FailureApiClient::try_new(&base_url) + .expect("failure client") + .malformed() + .await + .expect_err("malformed JSON must fail"); + assert_eq!( + format!("{malformed_error:?}"), + "Failure(success response JSON was malformed)" + ); + assert!(!format!("{malformed_error:?}").contains("this is not json")); + + let oversized_error = FailureApiClient::builder(&base_url) + .expect("failure client builder") + .response_body_limit(8) + .build() + .expect("failure client") + .oversized() + .await + .expect_err("oversized body must fail"); + assert!(matches!( + oversized_error, + api_reqwest::ClientError::Failure(api_reqwest::ClientFailure::ResponseTooLarge { + limit: 8 + }) + )); + assert!(!format!("{oversized_error:?}").contains("response-content")); + + let timeout_error = FailureApiClient::builder(&base_url) + .expect("failure client builder") + .request_timeout(Duration::from_millis(10)) + .build() + .expect("failure client") + .slow() + .await + .expect_err("slow response must time out"); + assert!(matches!( + timeout_error, + api_reqwest::ClientError::Failure(api_reqwest::ClientFailure::Timeout) + )); + + let _route_specific = widget_api_axum::create(Arc::new(WidgetService)); + server.abort(); +} + +#[test] +fn base_urls_are_strict_and_named_adapter_types_are_public() { + assert!(api_reqwest::BaseUrl::try_from("ftp://example.test/").is_err()); + assert!(api_reqwest::BaseUrl::try_from("https://user@example.test/").is_err()); + assert!(api_reqwest::BaseUrl::try_from("https://example.test/?token=secret").is_err()); + assert!(api_reqwest::BaseUrl::try_from("https://example.test/#fragment").is_err()); + + fn assert_client(_: &WidgetApiClient) {} + fn assert_builder(_: &WidgetApiClientBuilder) {} + let client = WidgetApiClient::try_new("https://example.test/").expect("client"); + let builder = WidgetApiClient::builder("https://example.test/").expect("builder"); + assert_client(&client); + assert_builder(&builder); +} diff --git a/crates/api-macros/tests/openapi.rs b/crates/api-macros/tests/openapi.rs new file mode 100644 index 000000000..1a79d6d30 --- /dev/null +++ b/crates/api-macros/tests/openapi.rs @@ -0,0 +1,399 @@ +#![cfg(feature = "openapi")] +#![allow(async_fn_in_trait, dead_code)] + +use api_macros::{ + api, + openapi::{OpenApiInfo, OpenApiSchema}, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_json::{Value, json}; + +fn nullable_string_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + generator.subschema_for::>() +} + +#[derive(Debug, Deserialize, JsonSchema, Serialize)] +pub struct GeoPoint { + latitude: f64, + longitude: f64, +} +impl OpenApiSchema for GeoPoint {} + +/// A custom Serde wire representation admitted only after an explicit, +/// matching schema hook and `OpenApiSchema` attestation. +#[derive(Debug, JsonSchema)] +#[schemars(transparent)] +pub struct WireSlug(String); + +impl Serialize for WireSlug { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for WireSlug { + fn deserialize>(deserializer: D) -> Result { + String::deserialize(deserializer).map(Self) + } +} +impl OpenApiSchema for WireSlug {} + +#[derive(Debug, Deserialize, JsonSchema, Serialize)] +pub struct CreateWidget { + #[schemars(length(min = 1, max = 64))] + name: String, + #[schemars(range(min = 1, max = 1_000))] + count: u32, + #[schemars(range(min = -100, max = 100))] + bounded_balance: i64, + #[schemars(length(min = 1, max = 8))] + tags: Vec, + slug: WireSlug, + /// Missing and explicit null are separate wire states. + #[schemars(default, schema_with = "nullable_string_schema")] + optional_label: Option, + #[schemars(required, schema_with = "nullable_string_schema")] + required_nullable_label: Option, + location: GeoPoint, +} +impl OpenApiSchema for CreateWidget {} + +#[derive(Debug, Deserialize, JsonSchema, Serialize)] +pub struct Lookup { + detailed: bool, +} +impl OpenApiSchema for Lookup {} + +#[derive(Debug, Deserialize, JsonSchema, Serialize)] +pub struct Widget { + id: u32, + request: CreateWidget, +} +impl OpenApiSchema for Widget {} + +#[derive(Debug, Deserialize, JsonSchema, Serialize)] +#[serde(tag = "code", content = "details")] +pub enum PublicError { + Invalid { message: String }, +} +impl OpenApiSchema for PublicError {} + +type OptionalRequestId = Option; + +#[api(openapi)] +pub trait FixtureApi { + #[post( + "/widgets/{widget_id}", + operation_id = "widgets.create", + status = 201, + error_status = 422 + )] + async fn create( + &self, + #[path] widget_id: u32, + #[query] lookup: Lookup, + #[header("authorization")] authorization: String, + #[header("x-request-id")] request_id: OptionalRequestId, + #[body] request: CreateWidget, + ) -> Result; + + #[delete("/widgets/{widget_id}", operation_id = "widgets.delete", status = 204)] + async fn delete(&self, #[path] widget_id: u32) -> (); +} + +fn document() -> api_macros::openapi::OpenApiDocument { + fixture_api_openapi(OpenApiInfo { + title: "Fixture API", + version: "1.2.3", + source_digest: "git:0123456789abcdef", + }) + .expect("valid OpenAPI projection") +} + +#[test] +fn output_is_deterministic_and_accepted_by_an_independent_parser() { + let first = document().to_json().expect("render document"); + let second = document().to_json().expect("render document again"); + assert_eq!(first.as_bytes(), second.as_bytes()); + + let parsed = oas3::from_json(&first).expect("oas3 independently parses generated document"); + assert_eq!( + parsed + .validate_version() + .expect("valid OpenAPI version") + .major, + 3 + ); + + let value: Value = serde_json::from_str(&first).expect("valid JSON"); + assert_eq!(value["openapi"], "3.1.0"); + assert_eq!(value["info"]["x-yoi-source-digest"], "git:0123456789abcdef"); + assert!( + value.get("servers").is_none(), + "deployment topology is omitted" + ); + assert!(!first.contains("generatedAt")); + assert!(!first.contains("#/$defs/")); + + let schemas = value["components"]["schemas"] + .as_object() + .expect("component map"); + let mut references = Vec::new(); + collect_schema_references(&value, &mut references); + assert!(!references.is_empty()); + for reference in references { + let name = reference + .strip_prefix("#/components/schemas/") + .expect("only local component references are emitted"); + assert!( + schemas.contains_key(name), + "unresolved reference {reference}" + ); + } +} + +fn collect_schema_references<'a>(value: &'a Value, references: &mut Vec<&'a str>) { + match value { + Value::Object(object) => { + if let Some(reference) = object.get("$ref").and_then(Value::as_str) { + references.push(reference); + } + for value in object.values() { + collect_schema_references(value, references); + } + } + Value::Array(values) => { + for value in values { + collect_schema_references(value, references); + } + } + _ => {} + } +} + +#[test] +fn operations_and_components_preserve_the_wire_contract() { + let value = document().as_value().clone(); + let create = &value["paths"]["/widgets/{widget_id}"]["post"]; + assert_eq!(create["operationId"], "widgets.create"); + assert_eq!(create["security"], json!([{ "bearerAuth": [] }])); + assert_eq!( + create["responses"]["201"]["content"]["application/json"]["schema"], + json!({ + "$ref": "#/components/schemas/Widget" + }) + ); + assert_eq!( + create["responses"]["422"]["content"]["application/json"]["schema"], + json!({ + "$ref": "#/components/schemas/PublicError" + }) + ); + assert_eq!( + create["requestBody"]["content"]["application/json"]["schema"], + json!({ "$ref": "#/components/schemas/CreateWidget" }) + ); + + let parameters = create["parameters"].as_array().expect("parameter list"); + assert_eq!(parameters.len(), 3); + assert_eq!( + (parameters[0]["name"].as_str(), parameters[0]["in"].as_str()), + (Some("widget_id"), Some("path")) + ); + assert_eq!(parameters[0]["required"], true); + assert_eq!( + (parameters[1]["name"].as_str(), parameters[1]["in"].as_str()), + (Some("lookup"), Some("query")) + ); + assert_eq!(parameters[1]["explode"], true); + assert_eq!( + (parameters[2]["name"].as_str(), parameters[2]["in"].as_str()), + (Some("x-request-id"), Some("header")) + ); + assert_eq!(parameters[2]["required"], false); + + let schemas = value["components"]["schemas"] + .as_object() + .expect("components"); + assert_eq!( + value["components"]["securitySchemes"]["bearerAuth"], + json!({ "type": "http", "scheme": "bearer" }) + ); + for name in [ + "CreateWidget", + "GeoPoint", + "Lookup", + "PublicError", + "Widget", + "WireSlug", + "uint32", + "Nullable_string", + ] { + assert!( + schemas.contains_key(name), + "missing stable schema component {name}; found {:?}", + schemas.keys().collect::>() + ); + } + assert_eq!( + schemas["CreateWidget"]["properties"]["location"]["$ref"], + "#/components/schemas/GeoPoint" + ); + assert_eq!( + schemas["CreateWidget"]["properties"]["slug"]["$ref"], + "#/components/schemas/WireSlug" + ); + assert_eq!( + schemas["CreateWidget"]["properties"]["name"]["minLength"], + 1 + ); + assert_eq!( + schemas["CreateWidget"]["properties"]["name"]["maxLength"], + 64 + ); + assert_eq!( + schemas["CreateWidget"]["properties"]["count"]["maximum"], + 1_000 + ); + assert_eq!( + schemas["CreateWidget"]["properties"]["bounded_balance"]["minimum"], + -100 + ); + assert_eq!( + schemas["CreateWidget"]["properties"]["bounded_balance"]["maximum"], + 100 + ); + assert_eq!(schemas["CreateWidget"]["properties"]["tags"]["minItems"], 1); + assert_eq!(schemas["CreateWidget"]["properties"]["tags"]["maxItems"], 8); + assert!(schemas["PublicError"]["oneOf"].is_array()); + + let required = schemas["CreateWidget"]["required"] + .as_array() + .expect("required fields"); + assert!(!required.contains(&json!("optional_label"))); + assert!(required.contains(&json!("required_nullable_label"))); + for field in ["optional_label", "required_nullable_label"] { + assert!( + schemas["CreateWidget"]["properties"][field]["type"] + .as_array() + .expect("nullable type union") + .contains(&json!("null")) + ); + } + + let delete = &value["paths"]["/widgets/{widget_id}"]["delete"]; + assert_eq!(delete["operationId"], "widgets.delete"); + assert!(delete.get("security").is_none()); + assert_eq!( + delete["responses"]["204"], + json!({ "description": "Successful response" }) + ); +} + +#[derive(Debug, JsonSchema)] +pub struct FirstCollision { + first: String, +} +impl OpenApiSchema for FirstCollision { + fn openapi_schema_name() -> std::borrow::Cow<'static, str> { + "SharedWireName".into() + } +} + +#[derive(Debug, JsonSchema)] +pub struct SecondCollision { + second: bool, +} +impl OpenApiSchema for SecondCollision { + fn openapi_schema_name() -> std::borrow::Cow<'static, str> { + "SharedWireName".into() + } +} + +#[derive(Debug, JsonSchema)] +#[serde(untagged)] +pub enum AmbiguousWireEnum { + Text(String), + Flag(bool), +} +impl OpenApiSchema for AmbiguousWireEnum {} + +#[derive(Debug)] +pub struct DanglingReference; + +impl JsonSchema for DanglingReference { + fn schema_name() -> std::borrow::Cow<'static, str> { + "DanglingReference".into() + } + + fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + schemars::json_schema!({ "$ref": "#/$defs/Missing" }) + } +} +impl OpenApiSchema for DanglingReference {} + +#[test] +fn schema_name_collisions_and_ambiguous_wire_shapes_fail_closed() { + use api_macros::openapi::{OpenApiBuilder, OpenApiError}; + + let info = OpenApiInfo { + title: "Rejected fixture", + version: "1", + source_digest: "git:bad", + }; + let mut collision = OpenApiBuilder::new(info).expect("builder"); + { + let mut operation = collision + .operation("GET", "/first", "first") + .expect("operation"); + operation + .response::(200, "application/json", "ok") + .expect("first schema"); + operation.finish().expect("first operation"); + } + let error = collision + .operation("GET", "/second", "second") + .expect("operation") + .response::(200, "application/json", "ok") + .expect_err("different schemas must not share a public name"); + assert!(matches!(error, OpenApiError::InvalidContract(_))); + assert!(error.to_string().contains("component name collision")); + + let mut ambiguous = OpenApiBuilder::new(info).expect("builder"); + let error = ambiguous + .operation("GET", "/ambiguous", "ambiguous") + .expect("operation") + .response::(200, "application/json", "ok") + .expect_err("untagged/ambiguous unions must fail closed"); + assert!(error.to_string().contains("untagged")); + + let mut dangling = OpenApiBuilder::new(info).expect("builder"); + { + let mut operation = dangling + .operation("GET", "/dangling", "dangling") + .expect("operation"); + operation + .response::(200, "application/json", "ok") + .expect("schema registration succeeds before graph validation"); + operation.finish().expect("operation"); + } + let error = dangling + .finish() + .expect_err("dangling component references must fail closed"); + assert!(error.to_string().contains("unresolved")); +} + +#[test] +fn file_export_writes_the_canonical_document() { + let path = std::env::temp_dir().join(format!( + "yoi-openapi-{}-{}.json", + std::process::id(), + std::thread::current().name().unwrap_or("test") + )); + let document = document(); + document.write_json(&path).expect("export OpenAPI file"); + let written = std::fs::read_to_string(&path).expect("read export"); + std::fs::remove_file(path).expect("remove export"); + assert_eq!(written, document.to_json().expect("canonical JSON")); +} diff --git a/crates/api-macros/tests/renamed_dependency.rs b/crates/api-macros/tests/renamed_dependency.rs new file mode 100644 index 000000000..6c0c5c9bd --- /dev/null +++ b/crates/api-macros/tests/renamed_dependency.rs @@ -0,0 +1,66 @@ +use std::{fs, path::PathBuf, process::Command}; + +#[test] +fn macro_expansion_resolves_a_renamed_support_dependency() { + let fixture = std::env::temp_dir().join(format!( + "api-macros-renamed-dependency-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&fixture); + fs::create_dir_all(fixture.join("src")).expect("create fixture source directory"); + + let package_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let package_path = package_path + .to_str() + .expect("package path is UTF-8") + .replace('\\', "\\\\"); + fs::write( + fixture.join("Cargo.toml"), + format!( + r#"[package] +name = "renamed-api-consumer" +version = "0.0.0" +edition = "2024" + +[workspace] + +[dependencies] +yoi-api = {{ package = "api-macros", path = "{package_path}" }} +"#, + ), + ) + .expect("write fixture manifest"); + fs::write( + fixture.join("src/lib.rs"), + r#"use yoi_api::{ApiContract, api}; + +pub struct Output; + +#[api] +pub trait RenamedApi { + #[get("/output", operation_id = "renamed.output")] + async fn output(&self) -> Output; +} + +pub fn operation_count() -> usize { + ::OPERATIONS.len() +} +"#, + ) + .expect("write fixture source"); + + let output = Command::new(env!("CARGO")) + .args(["check", "--quiet", "--offline"]) + .current_dir(&fixture) + .env("CARGO_TARGET_DIR", fixture.join("target")) + .output() + .expect("run cargo check for renamed dependency fixture"); + let _ = fs::remove_dir_all(&fixture); + + assert!( + output.status.success(), + "renamed dependency fixture failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); +} diff --git a/crates/api-macros/tests/ui/anonymous_body.rs b/crates/api-macros/tests/ui/anonymous_body.rs new file mode 100644 index 000000000..708ec9434 --- /dev/null +++ b/crates/api-macros/tests/ui/anonymous_body.rs @@ -0,0 +1,11 @@ +use api_macros::api; + +pub struct Response; + +#[api] +pub trait AnonymousBody { + #[post("/widgets")] + async fn create(&self, #[body] request: (u64, u64)) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/anonymous_body.stderr b/crates/api-macros/tests/ui/anonymous_body.stderr new file mode 100644 index 000000000..251ba1e12 --- /dev/null +++ b/crates/api-macros/tests/ui/anonymous_body.stderr @@ -0,0 +1,5 @@ +error: request bodies must use a named Rust type + --> tests/ui/anonymous_body.rs:8:45 + | +8 | async fn create(&self, #[body] request: (u64, u64)) -> Response; + | ^^^^^^^^^^ diff --git a/crates/api-macros/tests/ui/duplicate_operation_id.rs b/crates/api-macros/tests/ui/duplicate_operation_id.rs new file mode 100644 index 000000000..e75349269 --- /dev/null +++ b/crates/api-macros/tests/ui/duplicate_operation_id.rs @@ -0,0 +1,14 @@ +use api_macros::api; + +pub struct Response; + +#[api] +pub trait DuplicateOperationId { + #[get("/one", operation_id = "duplicate")] + async fn one(&self) -> Response; + + #[get("/two", operation_id = "duplicate")] + async fn two(&self) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/duplicate_operation_id.stderr b/crates/api-macros/tests/ui/duplicate_operation_id.stderr new file mode 100644 index 000000000..59bbd9096 --- /dev/null +++ b/crates/api-macros/tests/ui/duplicate_operation_id.stderr @@ -0,0 +1,5 @@ +error: duplicate operation ID `duplicate` + --> tests/ui/duplicate_operation_id.rs:11:14 + | +11 | async fn two(&self) -> Response; + | ^^^ diff --git a/crates/api-macros/tests/ui/duplicate_route.rs b/crates/api-macros/tests/ui/duplicate_route.rs new file mode 100644 index 000000000..86290c8dd --- /dev/null +++ b/crates/api-macros/tests/ui/duplicate_route.rs @@ -0,0 +1,14 @@ +use api_macros::api; + +pub struct Response; + +#[api] +pub trait DuplicateRoute { + #[get("/widgets", operation_id = "widgets.first")] + async fn first(&self) -> Response; + + #[get("/widgets", operation_id = "widgets.second")] + async fn second(&self) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/duplicate_route.stderr b/crates/api-macros/tests/ui/duplicate_route.stderr new file mode 100644 index 000000000..8f4999be7 --- /dev/null +++ b/crates/api-macros/tests/ui/duplicate_route.stderr @@ -0,0 +1,5 @@ +error: duplicate route for GET `/widgets` + --> tests/ui/duplicate_route.rs:11:14 + | +11 | async fn second(&self) -> Response; + | ^^^^^^ diff --git a/crates/api-macros/tests/ui/invalid_error_status.rs b/crates/api-macros/tests/ui/invalid_error_status.rs new file mode 100644 index 000000000..16e14af8e --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_error_status.rs @@ -0,0 +1,12 @@ +use api_macros::api; + +pub struct Response; +pub struct PublicError; + +#[api] +pub trait InvalidErrorStatus { + #[get("/widgets", error_status = 200)] + async fn list(&self) -> Result; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_error_status.stderr b/crates/api-macros/tests/ui/invalid_error_status.stderr new file mode 100644 index 000000000..efaaf8604 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_error_status.stderr @@ -0,0 +1,5 @@ +error: error_status must be between 400 and 599 + --> tests/ui/invalid_error_status.rs:8:38 + | +8 | #[get("/widgets", error_status = 200)] + | ^^^ diff --git a/crates/api-macros/tests/ui/invalid_method_body.rs b/crates/api-macros/tests/ui/invalid_method_body.rs new file mode 100644 index 000000000..0c8321b76 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_method_body.rs @@ -0,0 +1,12 @@ +use api_macros::api; + +pub struct Request; +pub struct Response; + +#[api] +pub trait InvalidMethodBody { + #[get("/widgets")] + async fn list(&self, #[body] request: Request) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_method_body.stderr b/crates/api-macros/tests/ui/invalid_method_body.stderr new file mode 100644 index 000000000..e4d47b676 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_method_body.stderr @@ -0,0 +1,5 @@ +error: GET operations cannot declare a request body + --> tests/ui/invalid_method_body.rs:9:14 + | +9 | async fn list(&self, #[body] request: Request) -> Response; + | ^^^^ diff --git a/crates/api-macros/tests/ui/invalid_success_status.rs b/crates/api-macros/tests/ui/invalid_success_status.rs new file mode 100644 index 000000000..bf80274cf --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_success_status.rs @@ -0,0 +1,11 @@ +use api_macros::api; + +pub struct Response; + +#[api] +pub trait InvalidSuccessStatus { + #[get("/widgets", status = 404)] + async fn list(&self) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/invalid_success_status.stderr b/crates/api-macros/tests/ui/invalid_success_status.stderr new file mode 100644 index 000000000..14e030505 --- /dev/null +++ b/crates/api-macros/tests/ui/invalid_success_status.stderr @@ -0,0 +1,5 @@ +error: status must be a success status between 200 and 299 + --> tests/ui/invalid_success_status.rs:7:32 + | +7 | #[get("/widgets", status = 404)] + | ^^^ diff --git a/crates/api-macros/tests/ui/marker_collision.rs b/crates/api-macros/tests/ui/marker_collision.rs new file mode 100644 index 000000000..b6a188f25 --- /dev/null +++ b/crates/api-macros/tests/ui/marker_collision.rs @@ -0,0 +1,14 @@ +use api_macros::api; + +pub struct Response; + +#[api] +pub trait MarkerCollision { + #[get("/first")] + async fn foo_bar(&self) -> Response; + + #[get("/second")] + async fn foo__bar(&self) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/marker_collision.stderr b/crates/api-macros/tests/ui/marker_collision.stderr new file mode 100644 index 000000000..4286e6991 --- /dev/null +++ b/crates/api-macros/tests/ui/marker_collision.stderr @@ -0,0 +1,11 @@ +error: generated operation marker `FooBar` collides with method `foo_bar` + --> tests/ui/marker_collision.rs:11:14 + | +11 | async fn foo__bar(&self) -> Response; + | ^^^^^^^^ + +error: first method generating marker `FooBar` + --> tests/ui/marker_collision.rs:8:14 + | +8 | async fn foo_bar(&self) -> Response; + | ^^^^^^^ diff --git a/crates/api-macros/tests/ui/multiple_bodies.rs b/crates/api-macros/tests/ui/multiple_bodies.rs new file mode 100644 index 000000000..1c8b139c6 --- /dev/null +++ b/crates/api-macros/tests/ui/multiple_bodies.rs @@ -0,0 +1,17 @@ +use api_macros::api; + +pub struct FirstBody; +pub struct SecondBody; +pub struct Response; + +#[api] +pub trait MultipleBodies { + #[post("/widgets")] + async fn create( + &self, + #[body] first: FirstBody, + #[body] second: SecondBody, + ) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/multiple_bodies.stderr b/crates/api-macros/tests/ui/multiple_bodies.stderr new file mode 100644 index 000000000..1b259838b --- /dev/null +++ b/crates/api-macros/tests/ui/multiple_bodies.stderr @@ -0,0 +1,5 @@ +error: an API operation may declare only one request body + --> tests/ui/multiple_bodies.rs:13:17 + | +13 | #[body] second: SecondBody, + | ^^^^^^ diff --git a/crates/api-macros/tests/ui/path_mismatch.rs b/crates/api-macros/tests/ui/path_mismatch.rs new file mode 100644 index 000000000..bec7543ae --- /dev/null +++ b/crates/api-macros/tests/ui/path_mismatch.rs @@ -0,0 +1,11 @@ +use api_macros::api; + +pub struct Response; + +#[api] +pub trait PathMismatch { + #[get("/widgets/{widget_id}")] + async fn get(&self, #[path] other_id: u64) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/path_mismatch.stderr b/crates/api-macros/tests/ui/path_mismatch.stderr new file mode 100644 index 000000000..c7c55cf66 --- /dev/null +++ b/crates/api-macros/tests/ui/path_mismatch.stderr @@ -0,0 +1,5 @@ +error: path placeholder(s) have no matching path parameter: widget_id + --> tests/ui/path_mismatch.rs:7:11 + | +7 | #[get("/widgets/{widget_id}")] + | ^^^^^^^^^^^^^^^^^^^^^^ diff --git a/crates/api-macros/tests/ui/private_body.rs b/crates/api-macros/tests/ui/private_body.rs new file mode 100644 index 000000000..50ee62629 --- /dev/null +++ b/crates/api-macros/tests/ui/private_body.rs @@ -0,0 +1,14 @@ +#![allow(async_fn_in_trait, private_interfaces)] + +use api_macros::api; + +struct PrivateRequest; +pub struct Response; + +#[api] +pub trait PrivateBody { + #[post("/widgets")] + async fn create(&self, #[body] request: PrivateRequest) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/private_body.stderr b/crates/api-macros/tests/ui/private_body.stderr new file mode 100644 index 000000000..c77408cd3 --- /dev/null +++ b/crates/api-macros/tests/ui/private_body.stderr @@ -0,0 +1,10 @@ +error[E0446]: private type `PrivateRequest` in public interface + --> tests/ui/private_body.rs:8:1 + | +5 | struct PrivateRequest; + | --------------------- `PrivateRequest` declared as private +... +8 | #[api] + | ^^^^^^ can't leak private type + | + = note: this error originates in the attribute macro `api` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/crates/api-macros/tests/ui/unsupported_wire_kind.rs b/crates/api-macros/tests/ui/unsupported_wire_kind.rs new file mode 100644 index 000000000..bb7f064d3 --- /dev/null +++ b/crates/api-macros/tests/ui/unsupported_wire_kind.rs @@ -0,0 +1,12 @@ +use api_macros::api; + +pub struct StreamRequest; +pub struct Response; + +#[api] +pub trait UnsupportedWireKind { + #[post("/widgets")] + async fn create(&self, #[stream] request: StreamRequest) -> Response; +} + +fn main() {} diff --git a/crates/api-macros/tests/ui/unsupported_wire_kind.stderr b/crates/api-macros/tests/ui/unsupported_wire_kind.stderr new file mode 100644 index 000000000..20da89534 --- /dev/null +++ b/crates/api-macros/tests/ui/unsupported_wire_kind.stderr @@ -0,0 +1,5 @@ +error: this wire kind is explicitly unsupported by the initial API contract + --> tests/ui/unsupported_wire_kind.rs:9:28 + | +9 | async fn create(&self, #[stream] request: StreamRequest) -> Response; + | ^^^^^^^^^ diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index bc3aa6bfd..81f695412 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -8,16 +8,17 @@ license.workspace = true async-trait.workspace = true chrono = { version = "0.4", default-features = false, features = ["clock"] } protocol = { workspace = true } +runtime-api.workspace = true ticket = { workspace = true } futures = { workspace = true } reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] } serde = { workspace = true } serde_json = { workspace = true } +server-api.workspace = true thiserror = { workspace = true } tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time"] } tokio-tungstenite = { workspace = true } uuid = { workspace = true } -workspace-api.workspace = true [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/client/src/backend_api.rs b/crates/client/src/backend_api.rs index 7e96d1896..5154a13f6 100644 --- a/crates/client/src/backend_api.rs +++ b/crates/client/src/backend_api.rs @@ -192,6 +192,10 @@ impl BackendApiClient { format!("Bearer {}", self.access_token.0) } + pub(crate) fn asynchronous_client(&self) -> reqwest::Client { + self.asynchronous.clone() + } + pub async fn require_success( &self, response: reqwest::Response, diff --git a/crates/client/src/backend_auth.rs b/crates/client/src/backend_auth.rs index 1237c4733..9d0f51ba0 100644 --- a/crates/client/src/backend_auth.rs +++ b/crates/client/src/backend_auth.rs @@ -3,8 +3,8 @@ use serde::Deserialize; use std::fmt; use std::time::Duration; -use workspace_api::{DeviceLoginPollRequest, DeviceLoginPollStatus, DeviceLoginStartRequest}; -pub use workspace_api::{DeviceLoginPollResponse, DeviceLoginStartResponse}; +use server_api::{DeviceLoginPollRequest, DeviceLoginPollStatus, DeviceLoginStartRequest}; +pub use server_api::{DeviceLoginPollResponse, DeviceLoginStartResponse}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct BackendAuthTarget { @@ -153,7 +153,7 @@ async fn parse_json_response Deserialize<'de>>( #[cfg(test)] mod tests { use super::*; - use workspace_api::DeviceAccessTokenType; + use server_api::DeviceAccessTokenType; fn poll_response(status: DeviceLoginPollStatus) -> DeviceLoginPollResponse { DeviceLoginPollResponse { diff --git a/crates/client/src/backend_runtime.rs b/crates/client/src/backend_runtime.rs index 8ec2ea84a..77c352827 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -2,11 +2,7 @@ use crate::transport::websocket::{Socket as WebSocket, SocketError as WebSocketE use crate::{BackendApiClient, BackendApiClientError, Client}; use reqwest::Method as HttpMethod; use serde::Deserialize; -use std::fmt; -use tokio_tungstenite::tungstenite::client::IntoClientRequest; -use tokio_tungstenite::tungstenite::http::HeaderValue; -use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; -pub use workspace_api::{ +pub use server_api::{ BrowserCreateWorkerResponse as BackendCreateWorkerResponse, CreateWorkspaceWorkerRequest as BackendCreateWorkerRequest, Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity, ListResponse as BackendRuntimeListResponse, @@ -18,7 +14,8 @@ pub use workspace_api::{ WorkerLaunchRuntimeOption as BackendWorkerLaunchRuntimeOption, WorkerOperationState as BackendWorkerOperationState, WorkerRestoreResponse as BackendWorkerRestoreResponse, - WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary, + WorkerRestoreResult as BackendWorkerRestoreResult, + WorkerRestoreState as BackendWorkerRestoreState, WorkerSummary as BackendWorkerSummary, WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary, WorkingDirectoryCreateRequest as BackendWorkingDirectoryCreateRequest, WorkingDirectoryCreateResponse as BackendWorkingDirectoryCreateResponse, @@ -26,8 +23,12 @@ pub use workspace_api::{ WorkingDirectoryListResponse as BackendWorkingDirectoryListResponse, WorkingDirectorySummary as BackendWorkingDirectorySummary, }; +use std::fmt; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::HeaderValue; +use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct BackendRuntimeTarget { /// Workspace Backend API root URL, for example `http://127.0.0.1:8787`. /// This is intentionally the Backend endpoint, not a Runtime endpoint. @@ -38,6 +39,8 @@ pub struct BackendRuntimeTarget { pub runtime_id: String, /// Backend-owned Worker identity used as path authority. pub worker_id: String, + pub initial_snapshot: Option, + pub initial_notice: Option, } impl BackendRuntimeTarget { @@ -52,6 +55,8 @@ impl BackendRuntimeTarget { workspace_id: workspace_id.into(), runtime_id: runtime_id.into(), worker_id: worker_id.into(), + initial_snapshot: None, + initial_notice: None, } } @@ -273,6 +278,7 @@ impl BackendRuntimeListTarget { pub enum BackendRuntimeClientError { InvalidTarget(String), Api(BackendApiClientError), + SessionApi(server_api::ServerApiClientError), Http(reqwest::Error), Protocol(String), } @@ -282,6 +288,7 @@ impl fmt::Display for BackendRuntimeClientError { match self { Self::InvalidTarget(message) => f.write_str(message), Self::Api(error) => write!(f, "{error}"), + Self::SessionApi(error) => write!(f, "{error}"), Self::Http(error) => write!(f, "{error}"), Self::Protocol(message) => f.write_str(message), } @@ -290,6 +297,12 @@ impl fmt::Display for BackendRuntimeClientError { impl std::error::Error for BackendRuntimeClientError {} +impl From for BackendRuntimeClientError { + fn from(value: server_api::ServerApiClientError) -> Self { + Self::SessionApi(value) + } +} + impl From for BackendRuntimeClientError { fn from(error: BackendApiClientError) -> Self { Self::Api(error) @@ -302,6 +315,59 @@ impl From for BackendRuntimeClientError { } } +const WORKER_SESSION_RESPONSE_LIMIT: usize = 16 * 1024 * 1024; + +#[derive(Clone, Debug)] +struct StoredBearerAuthorizer { + authorization: String, +} + +impl server_api::client_support::RequestAuthorizer for StoredBearerAuthorizer { + fn authorize( + &self, + _request: server_api::client_support::AuthorizerRequest<'_>, + ) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + self.authorization + .parse() + .map_err(|_| server_api::client_support::AuthorizationError::new())?, + ); + Ok(headers) + } +} + +pub async fn observe_backend_worker_session( + target: &BackendRuntimeTarget, +) -> Result { + validate_target(target)?; + let backend = BackendApiClient::from_stored_token(&target.base_url)?; + observe_backend_worker_session_with_client(target, &backend).await +} + +async fn observe_backend_worker_session_with_client( + target: &BackendRuntimeTarget, + backend: &BackendApiClient, +) -> Result { + let client = server_api::ServerApiClient::builder(&target.base_url) + .map_err(|error| BackendRuntimeClientError::Protocol(error.to_string()))? + .authorizer(StoredBearerAuthorizer { + authorization: backend.authorization_header_value(), + }) + .response_body_limit(WORKER_SESSION_RESPONSE_LIMIT) + .build() + .map_err(|error| BackendRuntimeClientError::Protocol(error.to_string()))?; + client + .worker_session( + target.workspace_id.clone(), + target.runtime_id.clone(), + target.worker_id.clone(), + ) + .await + .map_err(Into::into) +} + pub async fn get_backend_worker_launch_options( target: &BackendWorkerLaunchTarget, ) -> Result { @@ -760,6 +826,43 @@ mod tests { (base_url, task) } + #[tokio::test] + async fn worker_session_request_uses_bearer_and_sixteen_mib_response_limit() { + let large_message = "x".repeat(2 * 1024 * 1024); + let (base_url, server) = serve_json_once(serde_json::json!({ + "subject": { + "kind": "runtime_worker", + "runtime_id": "runtime-a", + "worker_id": "worker-a" + }, + "availability": "unavailable", + "reason": "storage_unavailable", + "message": large_message + })) + .await; + let target = BackendRuntimeTarget::new(&base_url, "workspace-a", "runtime-a", "worker-a"); + let api = + BackendApiClient::from_access_token_for_test(&base_url, "session-secret").unwrap(); + + let response = observe_backend_worker_session_with_client(&target, &api) + .await + .unwrap(); + assert!(matches!( + response.observation, + runtime_api::WorkerSessionAvailability::Unavailable { ref message, .. } + if message.len() == 2 * 1024 * 1024 + )); + let request = server.await.unwrap(); + assert!(request.starts_with( + "GET /api/w/workspace-a/runtimes/runtime-a/workers/worker-a/session HTTP/1.1\r\n" + )); + assert!( + request + .to_ascii_lowercase() + .contains("authorization: bearer session-secret\r\n") + ); + } + #[tokio::test] async fn launch_options_request_uses_workspace_path_and_bearer_auth() { let (base_url, server) = serve_json_once(serde_json::json!({ diff --git a/crates/client/src/backend_workspace.rs b/crates/client/src/backend_workspace.rs index 60edbc7a1..edd6cc75a 100644 --- a/crates/client/src/backend_workspace.rs +++ b/crates/client/src/backend_workspace.rs @@ -1,11 +1,10 @@ use crate::{BackendApiClient, BackendApiClientError}; use reqwest::Method; -use std::fmt; -use workspace_api::{ - InitialRepositoryIntent, RepositoryListResponse, RepositorySummary, - WorkspaceCatalogListResponse, WorkspaceCreateRequest, WorkspaceCreateResponse, - WorkspaceSummary, +use server_api::{ + InitialRepositoryIntent, RepositorySummary, WorkspaceCatalogListResponse, + WorkspaceCreateRequest, WorkspaceCreateResponse, WorkspaceSummary, }; +use std::fmt; const DEFAULT_WORKSPACE_LIMIT: usize = 200; @@ -32,6 +31,7 @@ pub enum BackendWorkspaceClientError { InvalidTarget(String), Api(BackendApiClientError), Http(reqwest::Error), + RepositoryApi(server_api::client_support::ClientError), } impl fmt::Display for BackendWorkspaceClientError { @@ -40,6 +40,7 @@ impl fmt::Display for BackendWorkspaceClientError { Self::InvalidTarget(message) => f.write_str(message), Self::Api(error) => write!(f, "{error}"), Self::Http(error) => write!(f, "{error}"), + Self::RepositoryApi(error) => write!(f, "{error}"), } } } @@ -58,6 +59,62 @@ impl From for BackendWorkspaceClientError { } } +impl From> + for BackendWorkspaceClientError +{ + fn from( + error: server_api::client_support::ClientError, + ) -> Self { + Self::RepositoryApi(error) + } +} + +fn repository_client_error( + backend: &BackendApiClient, + error: server_api::client_support::ClientError, +) -> BackendWorkspaceClientError { + let status = match &error { + server_api::client_support::ClientError::Public { status, .. } => Some(*status), + server_api::client_support::ClientError::Failure(_) => None, + }; + match status { + Some(reqwest::StatusCode::UNAUTHORIZED) => { + BackendWorkspaceClientError::Api(BackendApiClientError::Unauthorized { + origin: backend.origin().clone(), + }) + } + Some(reqwest::StatusCode::FORBIDDEN) => { + BackendWorkspaceClientError::Api(BackendApiClientError::Forbidden { + origin: backend.origin().clone(), + }) + } + _ => BackendWorkspaceClientError::RepositoryApi(error), + } +} + +#[derive(Clone, Debug)] +struct RepositoryBearerAuthorizer { + authorization: String, +} + +impl server_api::client_support::RequestAuthorizer for RepositoryBearerAuthorizer { + fn authorize( + &self, + _request: server_api::client_support::AuthorizerRequest<'_>, + ) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + reqwest::header::AUTHORIZATION, + self.authorization + .parse() + .map_err(|_| server_api::client_support::AuthorizationError::new())?, + ); + Ok(headers) + } +} + +const REPOSITORY_RESPONSE_LIMIT: usize = 16 * 1024 * 1024; + pub fn list_backend_workspaces_blocking( target: &BackendWorkspaceCatalogTarget, ) -> Result, BackendWorkspaceClientError> { @@ -75,6 +132,21 @@ pub fn list_backend_workspaces_blocking( pub fn list_backend_workspace_repositories_blocking( target: &BackendWorkspaceCatalogTarget, workspace_id: &str, +) -> Result, BackendWorkspaceClientError> { + let client = BackendApiClient::from_stored_token(&target.base_url)?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?; + runtime.block_on(list_backend_workspace_repositories_with_client( + &client, + workspace_id, + )) +} + +async fn list_backend_workspace_repositories_with_client( + backend: &BackendApiClient, + workspace_id: &str, ) -> Result, BackendWorkspaceClientError> { if workspace_id.is_empty() || workspace_id.len() > 200 @@ -86,12 +158,19 @@ pub fn list_backend_workspace_repositories_blocking( "Workspace id returned by Backend is invalid".to_string(), )); } - let client = BackendApiClient::from_stored_token(&target.base_url)?; + let client = server_api::ServerApiClient::builder(backend.origin().as_str()) + .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))? + .client(backend.asynchronous_client()) + .authorizer(RepositoryBearerAuthorizer { + authorization: backend.authorization_header_value(), + }) + .response_body_limit(REPOSITORY_RESPONSE_LIMIT) + .build() + .map_err(|error| BackendWorkspaceClientError::InvalidTarget(error.to_string()))?; let response = client - .blocking_request(Method::GET, &format!("/api/w/{workspace_id}/repositories"))? - .send()?; - client.check_status(response.status())?; - let response = response.json::()?; + .repository_list(workspace_id.to_owned()) + .await + .map_err(|error| repository_client_error(backend, error))?; if response.workspace_id != workspace_id { return Err(BackendWorkspaceClientError::InvalidTarget( "Repository catalog response does not match the requested Workspace".to_string(), @@ -170,6 +249,124 @@ mod tests { handle.join().unwrap(); } + #[tokio::test] + async fn repository_catalog_uses_generated_path_and_bearer_authorizer() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = vec![0; 4096]; + let read = stream.read(&mut request).unwrap(); + let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase(); + assert!(request.starts_with("get /api/w/workspace-test/repositories ")); + assert!(request.contains("authorization: bearer repository-secret\r\n")); + let body = r#"{"workspace_id":"workspace-test","items":[],"source":"workspace","diagnostics":[]}"#; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ) + .unwrap(); + }); + let client = + BackendApiClient::from_access_token_for_test(&base_url, "repository-secret").unwrap(); + + assert!( + list_backend_workspace_repositories_with_client(&client, "workspace-test") + .await + .unwrap() + .is_empty() + ); + handle.join().unwrap(); + } + + #[tokio::test] + async fn repository_catalog_enforces_generated_response_limit() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = vec![0; 4096]; + let _ = stream.read(&mut request).unwrap(); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + REPOSITORY_RESPONSE_LIMIT + 1, + ) + .unwrap(); + stream.flush().unwrap(); + let chunk = [b' '; 8192]; + let mut remaining = REPOSITORY_RESPONSE_LIMIT + 1; + while remaining > 0 { + let length = remaining.min(chunk.len()); + if stream.write_all(&chunk[..length]).is_err() { + break; + } + remaining -= length; + } + }); + let client = + BackendApiClient::from_access_token_for_test(&base_url, "repository-secret").unwrap(); + + let error = list_backend_workspace_repositories_with_client(&client, "workspace-test") + .await + .unwrap_err(); + assert!( + matches!( + &error, + BackendWorkspaceClientError::RepositoryApi( + server_api::client_support::ClientError::Failure( + server_api::client_support::ClientFailure::ResponseTooLarge { + limit: REPOSITORY_RESPONSE_LIMIT + } + ) + ) + ), + "unexpected error: {error:?}" + ); + handle.join().unwrap(); + } + + #[test] + fn repository_client_preserves_authentication_error_taxonomy() { + let client = + BackendApiClient::from_access_token_for_test("https://backend.example.test", "token") + .unwrap(); + let unauthorized = repository_client_error( + &client, + server_api::client_support::ClientError::public( + reqwest::StatusCode::UNAUTHORIZED, + server_api::RepositoryApiError::new( + 401, + "Unauthorized", + "authentication required", + Vec::new(), + ), + ), + ); + assert!(matches!( + unauthorized, + BackendWorkspaceClientError::Api(BackendApiClientError::Unauthorized { .. }) + )); + let forbidden = repository_client_error( + &client, + server_api::client_support::ClientError::public( + reqwest::StatusCode::FORBIDDEN, + server_api::RepositoryApiError::new( + 403, + "Forbidden", + "repository access denied", + Vec::new(), + ), + ), + ); + assert!(matches!( + forbidden, + BackendWorkspaceClientError::Api(BackendApiClientError::Forbidden { .. }) + )); + } + #[test] fn create_request_keeps_operation_key_for_exact_retry() { let request = CreateBackendWorkspaceRequest { diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 814e08cdf..554f88c0d 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -27,10 +27,10 @@ pub use backend_runtime::{ BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendWorkerLaunchOptions, BackendWorkerLaunchProfileCandidate, BackendWorkerLaunchRuntimeOption, BackendWorkerLaunchTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse, - BackendWorkerRestoreResult, BackendWorkerSummary, BackendWorkerWorkspaceSummary, - BackendWorkingDirectorySummary, connect_backend_runtime, create_backend_worker, - get_backend_worker_launch_options, list_backend_stopped_workers, list_backend_workers, - restore_backend_worker, + BackendWorkerRestoreResult, BackendWorkerRestoreState, BackendWorkerSummary, + BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, connect_backend_runtime, + create_backend_worker, get_backend_worker_launch_options, list_backend_stopped_workers, + list_backend_workers, observe_backend_worker_session, restore_backend_worker, }; pub use backend_workspace::{ BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError, @@ -40,15 +40,19 @@ pub use backend_workspace::{ list_backend_workspaces_blocking, }; pub use client::{Client, ClientError}; -pub use target::{ - BackendTarget, BackendWorkerLaunch, Dashboard, ResolvedTarget, StandaloneTarget, - StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, Target, TargetError, TargetKind, - WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn, +pub use runtime_api::{ + RetainedSessionIdentity, WorkerSessionAvailability, WorkerSessionUnavailableReason, }; -pub use workspace_api::{ +pub use server_api::{ CompanionCancelRequest, CompanionLifecycleState, CompanionMessageDisposition, CompanionMessageRequest, CompanionMessageResponse, CompanionStatusResponse, CompanionTranscriptItem, CompanionTranscriptProjection, CompanionTranscriptRole, CompanionTransportSummary, ObjectiveDetail, ObjectiveSummary, }; +pub use server_api::{ServerApi, ServerApiClient, WorkspaceWorkerSessionResponse}; +pub use target::{ + BackendTarget, BackendWorkerLaunch, Dashboard, ResolvedTarget, StandaloneTarget, + StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, Target, TargetError, TargetKind, + WorkerConnection, WorkerConnectionSelector, WorkerList, WorkerListRequest, WorkerSpawn, +}; pub use workspace_product::BackendWorkspaceProductClient; diff --git a/crates/client/src/target.rs b/crates/client/src/target.rs index 1d2aea9bc..bc9a861aa 100644 --- a/crates/client/src/target.rs +++ b/crates/client/src/target.rs @@ -134,7 +134,7 @@ pub struct WorkerList { pub include_stopped: bool, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct WorkerConnection { pub target: BackendRuntimeTarget, } diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index 030115b2c..4ba758f17 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -1,14 +1,7 @@ use reqwest::Method; use serde::Serialize; use serde::de::DeserializeOwned; -use ticket::{ - MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation, - OrchestrationPlanKind, OrchestrationPlanRecord, Ticket, TicketBackend, TicketDependencyCheck, - TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketItemEdit, - TicketListQuery, TicketListState, TicketMarkReady, TicketRef, TicketRelation, - TicketRelationKind, TicketRelationView, TicketStateChange, TicketStateSelector, TicketSummary, -}; -use workspace_api::{ +use server_api::{ BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest, @@ -17,6 +10,13 @@ use workspace_api::{ TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource, }; +use ticket::{ + MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation, + OrchestrationPlanKind, OrchestrationPlanRecord, Ticket, TicketBackend, TicketDependencyCheck, + TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketItemEdit, + TicketListQuery, TicketListState, TicketMarkReady, TicketRef, TicketRelation, + TicketRelationKind, TicketRelationView, TicketStateChange, TicketStateSelector, TicketSummary, +}; use crate::{BackendApiClient, BackendWorkspaceClientError}; @@ -758,7 +758,7 @@ mod tests { .as_ref() .unwrap() .kind, - workspace_api::MemoryEvidenceOriginKind::WorkerInput + server_api::MemoryEvidenceOriginKind::WorkerInput ); assert!( request diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 6f04d0f67..b53729543 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -16,14 +16,6 @@ pub fn new_submission_request_id() -> String { uuid::Uuid::now_v7().to_string() } -fn default_true() -> bool { - true -} - -fn is_true(value: &bool) -> bool { - *value -} - fn is_false(value: &bool) -> bool { !*value } @@ -231,25 +223,20 @@ pub enum Method { #[serde(skip_deserializing, default)] source: AuthenticatedInputSource, }, - /// Human-readable text injected into the target Worker's LLM context - /// as a non-blocking system message. `auto_run` controls whether an - /// idle target is kicked into `RunForNotification`; weak notifications - /// (`auto_run: false`) are only queued for the next turn/resume/run. - /// No side effects beyond LLM context; use `WorkerEvent` for typed - /// lifecycle reports. + /// Human-readable text injected into the target Worker's LLM context as a + /// non-blocking system message. Accepted notifications are delivered at the + /// next between-turn boundary, or start a coalesced notification run while + /// the Worker is idle. No side effects beyond LLM context; use + /// `WorkerEvent` for typed lifecycle reports. Notify { notification_request_id: String, message: String, - #[serde(default = "default_true", skip_serializing_if = "is_true")] - auto_run: bool, }, /// Authenticated transport form of Notify. #[cfg_attr(feature = "typescript", ts(skip))] NotifyTracked { notification_request_id: String, message: String, - #[serde(default = "default_true", skip_serializing_if = "is_true")] - auto_run: bool, #[serde(skip_deserializing, default)] source: AuthenticatedInputSource, }, @@ -2040,26 +2027,16 @@ mod tests { } #[test] - fn method_notify_json_roundtrip_defaults_to_auto_run() { + fn method_notify_json_roundtrip_has_no_delivery_mode() { let json = r#"{"method":"notify","params":{"notification_request_id":"notification-1","message":"turn done"}}"#; let method: Method = serde_json::from_str(json).unwrap(); assert!(matches!( method, - Method::Notify { ref message, auto_run: true, .. } if message == "turn done" + Method::Notify { ref message, .. } if message == "turn done" )); let serialized = serde_json::to_string(&method).unwrap(); assert_eq!(serialized, json); - } - - #[test] - fn method_notify_weak_json_roundtrip_serializes_auto_run_false() { - let json = r#"{"method":"notify","params":{"notification_request_id":"notification-1","message":"progress","auto_run":false}}"#; - let method: Method = serde_json::from_str(json).unwrap(); - assert!(matches!( - method, - Method::Notify { ref message, auto_run: false, .. } if message == "progress" - )); - assert_eq!(serde_json::to_string(&method).unwrap(), json); + assert!(!serialized.contains("auto_run")); } #[test] diff --git a/crates/protocol/src/subscription.rs b/crates/protocol/src/subscription.rs index bf4a86353..6d25c6d04 100644 --- a/crates/protocol/src/subscription.rs +++ b/crates/protocol/src/subscription.rs @@ -559,6 +559,19 @@ pub enum SubscriptionWorkerState { Stopped, } +/// Whether the Workspace Server has a current Runtime observation for a Worker. +/// +/// `Unavailable` deliberately does not imply `Stopped`: it means the last observed +/// lifecycle state is retained while the Runtime observation source is unavailable. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum SubscriptionWorkerAvailability { + #[default] + Observed, + Unavailable, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct SubscriptionWorker { @@ -571,6 +584,10 @@ pub struct SubscriptionWorker { /// Workspace-facing projections must populate it before publishing the Worker. #[serde(default, skip_serializing_if = "Option::is_none")] pub resource_key: Option, + /// Freshness of the Runtime-backed observation carried by this projection. + /// `Unavailable` preserves catalog membership without claiming that execution stopped. + #[serde(default)] + pub availability: SubscriptionWorkerAvailability, /// Producer-owned monotonic revision for this Worker subject. pub subject_revision: u64, /// Latest revisioned foreground state observed from the Worker. This remains @@ -878,6 +895,7 @@ mod tests { worker_id: worker_id(value), runtime_id: None, resource_key: None, + availability: SubscriptionWorkerAvailability::Observed, subject_revision: 0, worker_state: None, state: SubscriptionWorkerState::Idle, diff --git a/crates/protocol/src/typescript.rs b/crates/protocol/src/typescript.rs index 22527d639..9ea536cd9 100644 --- a/crates/protocol/src/typescript.rs +++ b/crates/protocol/src/typescript.rs @@ -21,8 +21,8 @@ use crate::{ SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest, SubscriptionRequestId, SubscriptionResponse, SubscriptionSnapshot, SubscriptionTerminationCode, SubscriptionWorkdirId, SubscriptionWorker, - SubscriptionWorkerId, SubscriptionWorkerIds, SubscriptionWorkerProtocolMethod, - SubscriptionWorkerState, WorkspaceSubscriptionWorkdir, + SubscriptionWorkerAvailability, SubscriptionWorkerId, SubscriptionWorkerIds, + SubscriptionWorkerProtocolMethod, SubscriptionWorkerState, WorkspaceSubscriptionWorkdir, }, }; @@ -112,6 +112,7 @@ pub fn generated_protocol_types() -> String { push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); diff --git a/crates/runtime-api/Cargo.toml b/crates/runtime-api/Cargo.toml new file mode 100644 index 000000000..4f250aad5 --- /dev/null +++ b/crates/runtime-api/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "runtime-api" +description = "Typed JSON REST contract for Yoi Runtime Worker management" +version = "0.1.0" +edition.workspace = true +license.workspace = true + +[dependencies] +api-macros = { workspace = true, features = ["reqwest", "axum"] } +manifest.workspace = true +protocol.workspace = true +serde = { workspace = true, features = ["derive"] } +workdir.workspace = true + +[dev-dependencies] +serde_json.workspace = true +tokio = { workspace = true, features = ["macros", "net", "rt-multi-thread"] } diff --git a/crates/runtime-api/src/lib.rs b/crates/runtime-api/src/lib.rs new file mode 100644 index 000000000..c6ab08b3a --- /dev/null +++ b/crates/runtime-api/src/lib.rs @@ -0,0 +1,1100 @@ +//! Typed Worker Runtime management HTTP contract. +//! +//! This crate owns the public REST DTOs and generated client/server adapters only. Runtime domain +//! state, authorization, body limits, and operation permission policy remain in their existing +//! authorities. + +use std::collections::BTreeMap; + +use api_macros::api; +pub use api_macros::axum as server_support; +pub use api_macros::reqwest as client_support; +pub use api_macros::{ApiContract, HttpMethod}; +use protocol::{CompletionEntry, CompletionKind, Segment, WorkerId, WorkerStateSnapshot}; +use serde::{Deserialize, Serialize}; +use workdir::workspace::{MaterializerKind, RuntimeWorkerRef, RuntimeWorkingDirectorySummary}; + +pub const RUNTIME_API_VERSION: &str = "v1"; +pub const RUNTIME_API_BASE_PATH: &str = "/v1"; +/// Runtime transport classification for a materialized Repository source. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RepositorySourceKind { + LocalPath, + File, + Ssh, + Https, + Invalid, +} + +impl RepositorySourceKind { + pub const fn is_remote(self) -> bool { + matches!(self, Self::Ssh | Self::Https) + } + + pub const fn as_str(self) -> &'static str { + match self { + Self::LocalPath => "local_path", + Self::File => "file", + Self::Ssh => "ssh", + Self::Https => "https", + Self::Invalid => "invalid", + } + } + + pub fn parse(value: &str) -> Option { + Some(match value { + "local_path" => Self::LocalPath, + "file" => Self::File, + "ssh" => Self::Ssh, + "https" => Self::Https, + "http" | "invalid" => Self::Invalid, + _ => return None, + }) + } +} + +impl<'de> Deserialize<'de> for RepositorySourceKind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(&value).ok_or_else(|| { + serde::de::Error::unknown_variant( + &value, + &["local_path", "file", "ssh", "https", "invalid"], + ) + }) + } +} + +/// Runtime transport identity for one Repository source. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RepositorySource { + pub kind: RepositorySourceKind, + pub uri: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RepositoryAccessMode { + ReadOnly, + ReadWrite, +} + +/// Runtime transport outcome for an explicit Worker restore operation. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkerRestoreState { + Accepted, + Rejected, + RolledBack, + ReconciliationRequired, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RuntimeApiError { + pub error: RuntimeApiErrorDetail, + #[serde(skip)] + status: u16, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RuntimeApiErrorDetail { + pub code: String, + pub message: String, +} + +impl RuntimeApiError { + pub fn new(status: u16, code: impl Into, message: impl Into) -> Self { + Self { + error: RuntimeApiErrorDetail { + code: code.into(), + message: message.into(), + }, + status, + } + } + + pub fn status(&self) -> u16 { + self.status + } +} + +impl api_macros::HttpError for RuntimeApiError { + fn status_code(&self) -> u16 { + self.status + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RuntimePingResponse { + pub runtime_id: String, + pub protocol_version: u32, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeBackendKind { + Memory, + FsStore, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeStatus { + Running, + Stopped, +} + +fn unknown_platform_component() -> String { + "unknown".to_string() +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RuntimeSummary { + pub display_name: Option, + pub backend: RuntimeBackendKind, + pub status: RuntimeStatus, + pub worker_count: usize, + pub active_worker_count: usize, + pub stopped_worker_count: usize, + pub diagnostic_count: usize, + #[serde(default = "unknown_platform_component")] + pub os: String, + #[serde(default = "unknown_platform_component")] + pub arch: String, + #[serde(default)] + pub worker_creation_available: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RuntimeSummaryResponse { + pub runtime: RuntimeSummary, +} + +#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +pub struct WorkerRef { + pub worker_id: WorkerId, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkspaceApiRef { + pub workspace_id: String, + pub base_url: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkerWorkspaceApiRequest { + pub workspace_api: WorkspaceApiRef, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkerStatus { + Idle, + Running, + Paused, + Stopped, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum ProfileSelector { + Builtin(String), + Named(String), +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ProfileSourceGraphSummary { + pub source_count: usize, + pub total_source_bytes: u64, + pub entrypoints: BTreeMap, + pub import_count: usize, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ProfileSourceArchiveRef { + pub id: String, + pub digest: String, + pub size_bytes: u64, + pub source_graph: ProfileSourceGraphSummary, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ProfileSourceArchive { + pub reference: ProfileSourceArchiveRef, + pub content: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ProfileSourceArchiveSource { + Embedded { archive: ProfileSourceArchive }, + WorkspaceConfig { archive: ProfileSourceArchiveRef }, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ConfigBundleRef { + pub id: String, + pub digest: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RepositorySelector(pub String); + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkingDirectoryRepository { + pub id: String, + pub provider: String, + pub source: RepositorySource, + pub source_revision: u64, + pub source_fingerprint: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selector: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BackendResourceKind { + ProfileSourceArchive, + RepositorySshAccess, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BackendResourceOperation { + FetchArchive, + FetchOnce, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ResourceRedactionPolicy { + RuntimeInternalOnly, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct BackendResourceHandle { + pub kind: BackendResourceKind, + pub workspace_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worker_id: Option, + pub resource_id: String, + pub digest: String, + pub operation: BackendResourceOperation, + pub expires_at_unix_seconds: i64, + pub nonce: String, + pub revision: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generation: Option, + pub max_bytes: u64, + pub content_type: String, + pub redaction: ResourceRedactionPolicy, + pub audit_correlation_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_source_graph: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RepositorySshCredentialCandidate { + pub credential_id: String, + pub credential_revision: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RepositorySshMaterializationAccess { + pub credential_candidates: Vec, + pub host_trust_id: String, + pub host_trust_revision: u64, + pub access: RepositoryAccessMode, + pub expires_at_epoch_seconds: u64, + pub repository_id: String, + pub repository_source_fingerprint: String, + pub repository_uri: String, + pub secret_resource: BackendResourceHandle, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct RepositoryMaterializationContext { + pub workspace_id: String, + pub runtime_id: String, + pub operation_id: String, + pub config_revision: u64, + pub config_projection_digest: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ssh: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkingDirectoryRequest { + pub repository: WorkingDirectoryRepository, + #[serde(default)] + pub materializer: MaterializerKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend_workdir_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub materialization: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkingDirectoryClaim { + pub working_directory_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub relative_cwd: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkingDirectoryStatus { + pub summary: RuntimeWorkingDirectorySummary, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkerInputKind { + User, + Notify, + Compact, + ListRewindTargets, + RegisterPeer, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkerInput { + pub kind: WorkerInputKind, + pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub submission_request_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub segments: Option>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CreateWorkerRequest { + pub worker_id: WorkerId, + pub create_fingerprint: String, + pub profile: ProfileSelector, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + pub profile_source: ProfileSourceArchiveSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_bundle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub initial_input: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_directory_request: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_directory: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub worker_observation_enabled: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub worker_observation_grants: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_api: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub memory_settings: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkerSummary { + pub worker_ref: WorkerRef, + pub worker_id: WorkerId, + pub status: WorkerStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub created_at_ms: Option, + pub execution_metadata_available: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worker_state: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_directory: Option, + pub profile: ProfileSelector, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, + pub profile_source: ProfileSourceArchiveRef, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_bundle: Option, +} + +pub type WorkerDetail = WorkerSummary; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkersResponse { + pub workers: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkerResponse { + pub worker: WorkerDetail, +} + +/// Explicit restore outcome returned for every reachable Runtime restore attempt, +/// including preflight rejection and uncertain post-side-effect reconciliation. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkerRestoreResponse { + pub state: WorkerRestoreState, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worker: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason_code: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkerStatusFilter { + Stopped, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkerListQuery { + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkerDeleteResult { + pub worker_id: WorkerId, + pub deleted: bool, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkerDeleteResponse { + pub worker: WorkerDeleteResult, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkerLifecycleRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct EmptyObjectRequest {} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkerLifecycleAck { + pub worker_ref: WorkerRef, + pub status: WorkerStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worker_state: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkerLifecycleResponse { + pub ack: WorkerLifecycleAck, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkerSubmissionAck { + pub submission_request_id: String, + pub submission_id: String, + pub disposition: protocol::SubmissionDisposition, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkerInteractionAck { + pub worker_ref: WorkerRef, + pub status: WorkerStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub submission: Option, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkerInputResponse { + pub ack: WorkerInteractionAck, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CompletionRequest { + pub kind: CompletionKind, + #[serde(default)] + pub prefix: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct CompletionResponse { + pub kind: CompletionKind, + pub prefix: String, + pub entries: Vec, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionDisposition { + Archive, + Purge, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DiagnosticsDisposition { + Purge, + Retain, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkerRetentionInventory { + pub workspace_id: String, + pub runtime_id: String, + pub worker_id: WorkerId, + pub session_id: Option, + pub segment_ids: Vec, + pub session_bytes: u64, + pub diagnostics_bytes: u64, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkerRetentionExecutionRequest { + pub operation_id: String, + pub input_fingerprint: String, + pub archive_id: Option, + pub workspace_id: String, + pub source_runtime_id: String, + pub worker_id: WorkerId, + pub expected_worker_revision: String, + pub source_created_at: String, + pub removed_at: String, + pub effective_profile: Option, + pub retention_class: Option, + pub policy_id: String, + pub policy_revision: u64, + pub session_disposition: SessionDisposition, + pub diagnostics_disposition: DiagnosticsDisposition, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkerSessionArchiveManifest { + pub schema_version: u32, + pub archive_id: String, + pub workspace_id: String, + pub source_runtime_id: String, + pub source_worker_id: WorkerId, + pub source_session_id: String, + pub segment_ids: Vec, + pub source_created_at: String, + pub removed_at: String, + pub archived_at_unix_seconds: u64, + pub effective_profile: Option, + pub retention_class: Option, + pub content_checksum_sha256: String, + pub content_bytes: u64, + pub content_file_count: u64, + pub policy_id: String, + pub policy_revision: u64, + pub operation_id: String, + pub input_fingerprint: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct WorkerRetentionExecutionResult { + pub operation_id: String, + pub input_fingerprint: String, + pub expected_worker_revision: String, + pub worker_id: WorkerId, + pub session_disposition: SessionDisposition, + pub diagnostics_disposition: DiagnosticsDisposition, + pub archive: Option, + pub source_removed: bool, + pub diagnostics_retained: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkerSessionRequest { + pub workspace_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RetainedSessionIdentity { + pub session_id: String, + pub segment_id: String, + pub entry_count: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkerSessionUnavailableReason { + RetentionMissing, + ActivePointerMissing, + CorruptLog, + MigrationRequired, + RetentionExpired, + SnapshotTooLarge, + StorageUnavailable, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "availability", rename_all = "snake_case")] +pub enum WorkerSessionAvailability { + LiveProtocol, + RetainedSnapshot { + identity: RetainedSessionIdentity, + snapshot: protocol::SessionSnapshot, + }, + Unavailable { + reason: WorkerSessionUnavailableReason, + message: String, + }, +} + +#[api(reqwest, axum)] +pub trait RuntimeApi { + #[get("/v1/ping", status = 200, error_status = 400)] + async fn ping( + &self, + #[header("x-yoi-workspace-id")] workspace_id: String, + ) -> Result; + + #[get("/v1/runtime", status = 200, error_status = 400)] + async fn runtime_summary(&self) -> Result; + + #[get("/v1/workers", status = 200, error_status = 400)] + async fn list_workers( + &self, + #[query] query: WorkerListQuery, + ) -> Result; + + #[get("/v1/workers/{worker_id}", status = 200, error_status = 400)] + async fn get_worker( + &self, + #[path] worker_id: String, + ) -> Result; + + #[get("/v1/workers/{worker_id}/session", status = 200, error_status = 400)] + async fn worker_session( + &self, + #[path] worker_id: String, + #[query] request: WorkerSessionRequest, + ) -> Result; + + #[post("/v1/workers", status = 200, error_status = 400)] + async fn create_worker( + &self, + #[body] request: CreateWorkerRequest, + ) -> Result; + + #[delete("/v1/workers/{worker_id}", status = 200, error_status = 400)] + async fn delete_worker( + &self, + #[path] worker_id: String, + ) -> Result; + + #[post("/v1/workers/{worker_id}/input", status = 200, error_status = 400)] + async fn send_worker_input( + &self, + #[path] worker_id: String, + #[body] input: WorkerInput, + ) -> Result; + + #[post("/v1/workers/{worker_id}/stop", status = 200, error_status = 400)] + async fn stop_worker( + &self, + #[path] worker_id: String, + #[body] request: WorkerLifecycleRequest, + ) -> Result; + + #[post("/v1/workers/{worker_id}/cancel", status = 200, error_status = 400)] + async fn cancel_worker( + &self, + #[path] worker_id: String, + #[body] request: WorkerLifecycleRequest, + ) -> Result; + + #[post("/v1/workers/{worker_id}/restore", status = 200, error_status = 400)] + async fn restore_worker( + &self, + #[path] worker_id: String, + #[body] request: EmptyObjectRequest, + ) -> Result; + + #[post( + "/v1/workers/{worker_id}/workspace-api", + status = 200, + error_status = 400 + )] + async fn replace_worker_workspace_api( + &self, + #[path] worker_id: String, + #[body] request: WorkerWorkspaceApiRequest, + ) -> Result; + + #[post( + "/v1/workers/{worker_id}/completions", + status = 200, + error_status = 400 + )] + async fn complete_worker_arguments( + &self, + #[path] worker_id: String, + #[body] request: CompletionRequest, + ) -> Result; + + #[get( + "/v1/workers/{worker_id}/retention/inventory", + status = 200, + error_status = 400 + )] + async fn retention_inventory( + &self, + #[path] worker_id: String, + ) -> Result; + + #[post( + "/v1/workers/{worker_id}/retention/execute", + status = 200, + error_status = 400 + )] + async fn execute_retention( + &self, + #[path] worker_id: String, + #[body] request: WorkerRetentionExecutionRequest, + ) -> Result; +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RemainingRuntimeRoute { + pub method: &'static str, + pub path: &'static str, + pub reason: &'static str, +} + +pub const RUNTIME_ROUTE_VERIFICATION_CHALLENGE: &str = + "/v1/workspace-runtime-verification/challenge"; +pub const RUNTIME_ROUTE_VERIFICATION_ACK: &str = + "/v1/workspace-runtime-verification/acknowledgement"; +pub const RUNTIME_ROUTE_CONFIG_BUNDLES: &str = "/v1/config-bundles"; +pub const RUNTIME_ROUTE_CONFIG_BUNDLE_AVAILABILITY: &str = + "/v1/config-bundles/{bundle_id}/availability"; +pub const RUNTIME_ROUTE_WORKSPACE_PROMPT_PROJECTIONS: &str = "/v1/workspace-prompt-projections"; +pub const RUNTIME_ROUTE_WORKING_DIRECTORIES: &str = "/v1/working-directories"; +pub const RUNTIME_ROUTE_WORKING_DIRECTORY_REPOSITORY_ACCESS: &str = + "/v1/working-directories/repository-access"; +pub const RUNTIME_ROUTE_SSH_PROBE: &str = "/v1/repositories/ssh/probe"; +pub const RUNTIME_ROUTE_REPOSITORY_REFS_OBSERVE: &str = "/v1/repository-refs/observe"; +pub const RUNTIME_ROUTE_WORKDIR_SESSIONS: &str = + "/v1/working-directories/{working_directory_id}/sessions"; +pub const RUNTIME_ROUTE_WORKDIR_SESSION_OPERATIONS: &str = + "/v1/workdir-sessions/{session_id}/operations"; +pub const RUNTIME_ROUTE_WORKDIR_SESSION: &str = "/v1/workdir-sessions/{session_id}"; +pub const RUNTIME_ROUTE_WORKING_DIRECTORY: &str = "/v1/working-directories/{working_directory_id}"; +pub const RUNTIME_ROUTE_PROTOCOL_WS: &str = "/v1/protocol/ws"; +pub const RUNTIME_ROUTE_WORKER_PROTOCOL_WS: &str = "/v1/workers/{worker_id}/protocol/ws"; +pub const RUNTIME_ROUTE_WORKER_ATTACHMENTS: &str = "/v1/workers/{worker_id}/attachments"; +pub const RUNTIME_ROUTE_WORKER_ATTACHMENT: &str = + "/v1/workers/{worker_id}/attachments/{artifact_id}"; + +/// Intentionally out-of-contract Runtime routes. This inventory keeps manual paths visible rather +/// than letting them be mistaken for management-contract omissions. +pub const REMAINING_RUNTIME_ROUTES: &[RemainingRuntimeRoute] = &[ + RemainingRuntimeRoute { + method: "POST", + path: RUNTIME_ROUTE_VERIFICATION_CHALLENGE, + reason: "Workspace verification handshake", + }, + RemainingRuntimeRoute { + method: "POST", + path: RUNTIME_ROUTE_VERIFICATION_ACK, + reason: "Workspace verification handshake", + }, + RemainingRuntimeRoute { + method: "GET", + path: RUNTIME_ROUTE_CONFIG_BUNDLES, + reason: "Workspace Config transport", + }, + RemainingRuntimeRoute { + method: "POST", + path: RUNTIME_ROUTE_CONFIG_BUNDLES, + reason: "Workspace Config transport", + }, + RemainingRuntimeRoute { + method: "GET", + path: RUNTIME_ROUTE_CONFIG_BUNDLE_AVAILABILITY, + reason: "Workspace Config transport", + }, + RemainingRuntimeRoute { + method: "POST", + path: RUNTIME_ROUTE_WORKSPACE_PROMPT_PROJECTIONS, + reason: "Workspace Config transport", + }, + RemainingRuntimeRoute { + method: "GET", + path: RUNTIME_ROUTE_WORKING_DIRECTORIES, + reason: "Workdir transport", + }, + RemainingRuntimeRoute { + method: "POST", + path: RUNTIME_ROUTE_WORKING_DIRECTORIES, + reason: "Workdir transport", + }, + RemainingRuntimeRoute { + method: "POST", + path: RUNTIME_ROUTE_WORKING_DIRECTORY_REPOSITORY_ACCESS, + reason: "Workdir transport", + }, + RemainingRuntimeRoute { + method: "POST", + path: RUNTIME_ROUTE_SSH_PROBE, + reason: "Repository SSH transport", + }, + RemainingRuntimeRoute { + method: "POST", + path: RUNTIME_ROUTE_REPOSITORY_REFS_OBSERVE, + reason: "Repository observation transport", + }, + RemainingRuntimeRoute { + method: "POST", + path: RUNTIME_ROUTE_WORKDIR_SESSIONS, + reason: "Workdir session transport", + }, + RemainingRuntimeRoute { + method: "POST", + path: RUNTIME_ROUTE_WORKDIR_SESSION_OPERATIONS, + reason: "Workdir session transport", + }, + RemainingRuntimeRoute { + method: "DELETE", + path: RUNTIME_ROUTE_WORKDIR_SESSION, + reason: "Workdir session transport", + }, + RemainingRuntimeRoute { + method: "GET", + path: RUNTIME_ROUTE_WORKING_DIRECTORY, + reason: "Workdir transport", + }, + RemainingRuntimeRoute { + method: "DELETE", + path: RUNTIME_ROUTE_WORKING_DIRECTORY, + reason: "Workdir transport", + }, + RemainingRuntimeRoute { + method: "GET", + path: RUNTIME_ROUTE_PROTOCOL_WS, + reason: "WebSocket protocol transport (ws-server feature)", + }, + RemainingRuntimeRoute { + method: "GET", + path: RUNTIME_ROUTE_WORKER_PROTOCOL_WS, + reason: "WebSocket protocol transport (ws-server feature)", + }, + RemainingRuntimeRoute { + method: "POST", + path: RUNTIME_ROUTE_WORKER_ATTACHMENTS, + reason: "Binary attachment transport", + }, + RemainingRuntimeRoute { + method: "DELETE", + path: RUNTIME_ROUTE_WORKER_ATTACHMENT, + reason: "Binary attachment transport", + }, +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Clone)] + struct RoundTripService; + + fn test_error(status: u16) -> RuntimeApiError { + RuntimeApiError::new(status, "test_error", "test error") + } + + impl RuntimeApi for RoundTripService { + async fn ping( + &self, + _workspace_id: String, + ) -> Result { + Err(test_error(501)) + } + + async fn runtime_summary(&self) -> Result { + Ok(RuntimeSummaryResponse { + runtime: RuntimeSummary { + display_name: Some("round-trip".to_string()), + backend: RuntimeBackendKind::Memory, + status: RuntimeStatus::Running, + worker_count: 0, + active_worker_count: 0, + stopped_worker_count: 0, + diagnostic_count: 0, + os: "test".to_string(), + arch: "test".to_string(), + worker_creation_available: true, + }, + }) + } + + async fn list_workers( + &self, + query: WorkerListQuery, + ) -> Result { + assert_eq!(query.status, Some(WorkerStatusFilter::Stopped)); + Ok(WorkersResponse { + workers: Vec::new(), + }) + } + + async fn get_worker(&self, worker_id: String) -> Result { + assert_eq!(worker_id, "missing worker"); + Err(test_error(404)) + } + + async fn worker_session( + &self, + _worker_id: String, + _request: WorkerSessionRequest, + ) -> Result { + Ok(WorkerSessionAvailability::LiveProtocol) + } + + async fn create_worker( + &self, + _request: CreateWorkerRequest, + ) -> Result { + Err(test_error(501)) + } + async fn delete_worker( + &self, + _worker_id: String, + ) -> Result { + Err(test_error(501)) + } + async fn send_worker_input( + &self, + _worker_id: String, + _input: WorkerInput, + ) -> Result { + Err(test_error(501)) + } + async fn stop_worker( + &self, + _worker_id: String, + _request: WorkerLifecycleRequest, + ) -> Result { + Err(test_error(501)) + } + async fn cancel_worker( + &self, + _worker_id: String, + _request: WorkerLifecycleRequest, + ) -> Result { + Err(test_error(501)) + } + async fn restore_worker( + &self, + _worker_id: String, + _request: EmptyObjectRequest, + ) -> Result { + Err(test_error(501)) + } + async fn replace_worker_workspace_api( + &self, + _worker_id: String, + _request: WorkerWorkspaceApiRequest, + ) -> Result { + Err(test_error(501)) + } + async fn complete_worker_arguments( + &self, + _worker_id: String, + _request: CompletionRequest, + ) -> Result { + Err(test_error(501)) + } + async fn retention_inventory( + &self, + _worker_id: String, + ) -> Result { + Err(test_error(501)) + } + async fn execute_retention( + &self, + _worker_id: String, + _request: WorkerRetentionExecutionRequest, + ) -> Result { + Err(test_error(501)) + } + } + + #[tokio::test] + async fn generated_client_and_router_round_trip_paths_queries_and_errors() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + server_support::framework::serve(listener, RuntimeApiAxum::router(RoundTripService)) + .await + .unwrap(); + }); + let client = RuntimeApiClient::builder(&format!("http://{address}")) + .unwrap() + .build() + .unwrap(); + + let summary = client.runtime_summary().await.unwrap(); + assert_eq!(summary.runtime.display_name.as_deref(), Some("round-trip")); + let workers = client + .list_workers(WorkerListQuery { + status: Some(WorkerStatusFilter::Stopped), + }) + .await + .unwrap(); + assert!(workers.workers.is_empty()); + let error = client + .get_worker("missing worker".to_string()) + .await + .unwrap_err(); + assert!( + matches!(error, client_support::ClientError::Public { status, .. } if status.as_u16() == 404) + ); + server.abort(); + } + + #[test] + fn worker_session_availability_has_closed_tagged_wire_shape() { + let value = serde_json::to_value(WorkerSessionAvailability::Unavailable { + reason: WorkerSessionUnavailableReason::SnapshotTooLarge, + message: "snapshot exceeds limit".to_string(), + }) + .unwrap(); + assert_eq!(value["availability"], "unavailable"); + assert_eq!(value["reason"], "snapshot_too_large"); + assert_eq!(value["message"], "snapshot exceeds limit"); + } + + #[test] + fn contract_inventory_is_complete_and_unique() { + let operations = RuntimeApiMetadata::OPERATIONS; + assert_eq!(operations.len(), 15); + let mut routes = operations + .iter() + .map(|operation| (format!("{:?}", operation.method), operation.path)) + .collect::>(); + routes.sort_unstable(); + routes.dedup(); + assert_eq!(routes.len(), operations.len()); + assert!( + operations + .iter() + .all(|operation| operation.path.starts_with(RUNTIME_API_BASE_PATH)) + ); + } + + #[test] + fn remaining_route_inventory_does_not_overlap_contract() { + assert_eq!(REMAINING_RUNTIME_ROUTES.len(), 20); + for remaining in REMAINING_RUNTIME_ROUTES { + assert!(!RuntimeApiMetadata::OPERATIONS.iter().any(|operation| { + format!("{:?}", operation.method).eq_ignore_ascii_case(remaining.method) + && operation.path == remaining.path + })); + } + } +} diff --git a/crates/workspace-api/Cargo.toml b/crates/server-api/Cargo.toml similarity index 79% rename from crates/workspace-api/Cargo.toml rename to crates/server-api/Cargo.toml index 468c65472..d371d6db5 100644 --- a/crates/workspace-api/Cargo.toml +++ b/crates/server-api/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "workspace-api" +name = "server-api" version = "0.1.0" edition.workspace = true license.workspace = true @@ -10,17 +10,23 @@ default = [] typescript = ["dep:ts-rs", "protocol/typescript"] [dependencies] +api-macros = { workspace = true, features = ["reqwest", "axum", "openapi"] } protocol.workspace = true +runtime-api.workspace = true +schemars.workspace = true serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +sha2.workspace = true ts-rs = { version = "12.0.1", optional = true } webauthn-rs-proto = "0.5.5" - -[[example]] -name = "generate_typescript" -required-features = ["typescript"] +workdir.workspace = true [dev-dependencies] -serde_json.workspace = true +tokio.workspace = true + +[[example]] +name = "generate_legacy_typescript" +required-features = ["typescript"] [[example]] name = "generate_workdir_api_types" diff --git a/crates/server-api/examples/export_openapi.rs b/crates/server-api/examples/export_openapi.rs new file mode 100644 index 000000000..714c4c1d4 --- /dev/null +++ b/crates/server-api/examples/export_openapi.rs @@ -0,0 +1,54 @@ +use std::{env, fs, io, path::PathBuf}; + +const DEFAULT_OUTPUT: &str = "openapi/server-api.json"; + +fn main() -> Result<(), Box> { + let mut arguments = env::args_os().skip(1); + let first = arguments.next(); + let (mode, output) = match first.as_deref().and_then(|value| value.to_str()) { + Some("--check") => ("check", arguments.next().map(PathBuf::from)), + Some("--stdout") => ("stdout", None), + Some(_) => ("write", first.map(PathBuf::from)), + None => ("write", None), + }; + if arguments.next().is_some() { + return Err("usage: export_openapi [--check [PATH] | --stdout | PATH]".into()); + } + + let output = output.unwrap_or_else(|| PathBuf::from(DEFAULT_OUTPUT)); + let json = server_api::canonical_openapi_document()?.to_json()?; + match mode { + "stdout" => print!("{json}"), + "check" => { + let checked_in = fs::read_to_string(&output)?; + if checked_in != json { + return Err(format!( + "{} is stale; regenerate with `cargo run -p server-api --example export_openapi -- {}`", + output.display(), + output.display() + ) + .into()); + } + println!("{} is current", output.display()); + } + "write" => { + if let Some(parent) = output.parent() { + fs::create_dir_all(parent)?; + } + let mut temporary = output.clone(); + let extension = output + .extension() + .and_then(|value| value.to_str()) + .map_or_else(|| "tmp".to_owned(), |value| format!("{value}.tmp")); + temporary.set_extension(extension); + fs::write(&temporary, json)?; + fs::rename(&temporary, &output).or_else(|error| { + let _ = fs::remove_file(&temporary); + Err::<(), io::Error>(error) + })?; + println!("wrote {}", output.display()); + } + _ => unreachable!(), + } + Ok(()) +} diff --git a/crates/server-api/examples/generate_auth_api_types.rs b/crates/server-api/examples/generate_auth_api_types.rs new file mode 100644 index 000000000..776008b36 --- /dev/null +++ b/crates/server-api/examples/generate_auth_api_types.rs @@ -0,0 +1,6 @@ +fn main() { + print!( + "{}", + server_api::normalized_typescript(server_api::auth_api_typescript()) + ); +} diff --git a/crates/server-api/examples/generate_companion_api_types.rs b/crates/server-api/examples/generate_companion_api_types.rs new file mode 100644 index 000000000..fd402438a --- /dev/null +++ b/crates/server-api/examples/generate_companion_api_types.rs @@ -0,0 +1,6 @@ +fn main() { + print!( + "{}", + server_api::normalized_typescript(server_api::companion_api_typescript()) + ); +} diff --git a/crates/server-api/examples/generate_legacy_typescript.rs b/crates/server-api/examples/generate_legacy_typescript.rs new file mode 100644 index 000000000..4e2919bce --- /dev/null +++ b/crates/server-api/examples/generate_legacy_typescript.rs @@ -0,0 +1,6 @@ +fn main() { + print!( + "{}", + server_api::normalized_typescript(server_api::legacy_catalog_typescript()) + ); +} diff --git a/crates/server-api/examples/generate_memory_api_types.rs b/crates/server-api/examples/generate_memory_api_types.rs new file mode 100644 index 000000000..b0f841d8e --- /dev/null +++ b/crates/server-api/examples/generate_memory_api_types.rs @@ -0,0 +1,6 @@ +fn main() { + print!( + "{}", + server_api::normalized_typescript(server_api::memory_api_typescript()) + ); +} diff --git a/crates/server-api/examples/generate_repository_access_types.rs b/crates/server-api/examples/generate_repository_access_types.rs new file mode 100644 index 000000000..2049e707b --- /dev/null +++ b/crates/server-api/examples/generate_repository_access_types.rs @@ -0,0 +1,6 @@ +fn main() { + print!( + "{}", + server_api::normalized_typescript(server_api::repository_access_api_typescript()) + ); +} diff --git a/crates/server-api/examples/generate_repository_openapi_types.rs b/crates/server-api/examples/generate_repository_openapi_types.rs new file mode 100644 index 000000000..e7b9cfe21 --- /dev/null +++ b/crates/server-api/examples/generate_repository_openapi_types.rs @@ -0,0 +1,59 @@ +use std::fs; +use std::path::Path; + +use server_api::repository_openapi_typescript::{ + INPUT_PATH, OUTPUT_PATH, generate_repository_typescript, +}; + +fn main() { + if let Err(error) = run() { + eprintln!("{error}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), String> { + let mut check = false; + for argument in std::env::args().skip(1) { + match argument.as_str() { + "--check" => check = true, + "--help" | "-h" => { + println!( + "Generate the bounded Repository API TypeScript artifact.\n\n\ + Usage: cargo run -q -p server-api --example generate_repository_openapi_types -- [--check]" + ); + return Ok(()); + } + other => return Err(format!("unknown argument `{other}`")), + } + } + + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let input_path = root.join(INPUT_PATH); + let output_path = root.join(OUTPUT_PATH); + let input = fs::read_to_string(&input_path) + .map_err(|error| format!("failed to read {}: {error}", input_path.display()))?; + let generated = generate_repository_typescript(&input) + .map_err(|error| format!("failed to generate Repository API types: {error}"))?; + + if check { + let checked_in = fs::read_to_string(&output_path) + .map_err(|error| format!("failed to read {}: {error}", output_path.display()))?; + if checked_in != generated { + return Err(format!( + "{} is stale; regenerate it without --check", + output_path.display() + )); + } + println!("{} is current", output_path.display()); + return Ok(()); + } + + let temporary_path = output_path.with_extension("ts.tmp"); + fs::write(&temporary_path, generated) + .map_err(|error| format!("failed to write {}: {error}", temporary_path.display()))?; + fs::rename(&temporary_path, &output_path) + .map_err(|error| format!("failed to replace {}: {error}", output_path.display()))?; + println!("generated {}", output_path.display()); + Ok(()) +} diff --git a/crates/server-api/examples/generate_skill_api_types.rs b/crates/server-api/examples/generate_skill_api_types.rs new file mode 100644 index 000000000..b610b1bda --- /dev/null +++ b/crates/server-api/examples/generate_skill_api_types.rs @@ -0,0 +1,6 @@ +fn main() { + print!( + "{}", + server_api::normalized_typescript(server_api::skill_api_typescript()) + ); +} diff --git a/crates/server-api/examples/generate_workdir_api_types.rs b/crates/server-api/examples/generate_workdir_api_types.rs new file mode 100644 index 000000000..13ed5f08b --- /dev/null +++ b/crates/server-api/examples/generate_workdir_api_types.rs @@ -0,0 +1,6 @@ +fn main() { + print!( + "{}", + server_api::normalized_typescript(server_api::workdir_api_typescript()) + ); +} diff --git a/crates/server-api/examples/generate_worker_launch_api_types.rs b/crates/server-api/examples/generate_worker_launch_api_types.rs new file mode 100644 index 000000000..5e326b368 --- /dev/null +++ b/crates/server-api/examples/generate_worker_launch_api_types.rs @@ -0,0 +1,6 @@ +fn main() { + print!( + "{}", + server_api::normalized_typescript(server_api::worker_launch_api_typescript()) + ); +} diff --git a/crates/workspace-api/src/lib.rs b/crates/server-api/src/lib.rs similarity index 88% rename from crates/workspace-api/src/lib.rs rename to crates/server-api/src/lib.rs index c05750ab3..ebe0d0ccf 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -2,14 +2,208 @@ //! //! This crate owns transport DTOs exposed by the Workspace Server and consumed //! by Rust clients. Runtime-internal projections remain in their owning crates; -//! callers must explicitly construct these Workspace-authoritative resources. +//! callers must explicitly construct these Server-authoritative resources. +use api_macros::api; +pub use api_macros::axum as server_support; +pub use api_macros::reqwest as client_support; +pub use api_macros::{ApiContract, HttpMethod}; +pub mod repository_openapi_typescript; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use webauthn_rs_proto::{ CreationChallengeResponse, PublicKeyCredential, RegisterPublicKeyCredential, RequestChallengeResponse, }; +pub type ServerApiError = runtime_api::RuntimeApiError; +pub type ServerApiClientError = client_support::ClientError; + +/// Error body shared by the existing repository routes and their generated adapters. +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct RepositoryApiError { + pub error: String, + pub message: String, + #[serde(default)] + pub diagnostics: Vec, + #[serde(skip, default = "default_repository_error_status")] + status: u16, +} + +const fn default_repository_error_status() -> u16 { + 400 +} + +impl RepositoryApiError { + pub fn new( + status: u16, + error: impl Into, + message: impl Into, + diagnostics: Vec, + ) -> Self { + Self { + error: error.into(), + message: message.into(), + diagnostics, + status, + } + } +} + +impl api_macros::HttpError for RepositoryApiError { + fn status_code(&self) -> u16 { + self.status + } +} + +impl api_macros::HttpRequestError for RepositoryApiError { + fn from_request_rejection(status: u16, message: String) -> Self { + let reason = match status { + 413 => "Payload Too Large", + 415 => "Unsupported Media Type", + 422 => "Unprocessable Entity", + _ => "Bad Request", + }; + Self::new(status, reason, message, Vec::new()) + } +} + +macro_rules! impl_openapi_schema { + ($($ty:ty),+ $(,)?) => { + $(impl api_macros::openapi::OpenApiSchema for $ty {})+ + }; +} + +impl_openapi_schema!( + CreateWorkspaceRepositoryRequest, + CreateWorkspaceRepositoryResponse, + RepositoryApiError, + RepositoryListResponse, + RepositoryDetailResponse, +); + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct WorkspaceWorkerSessionResponse { + pub subject: WorkspaceWorkerSubject, + #[serde(flatten)] + pub observation: runtime_api::WorkerSessionAvailability, +} + +#[api(reqwest, axum, openapi)] +pub trait ServerApi { + #[get( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session", + status = 200, + error_status = 400, + openapi = false + )] + async fn worker_session( + &self, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[path] worker_id: String, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/repositories", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn repository_list( + &self, + #[path] workspace_id: String, + ) -> Result; + + #[get( + "/api/repositories", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + bearer_auth = true, + browser_auth = true + )] + async fn repository_list_alias(&self) -> Result; + + #[get( + "/api/w/{workspace_id}/repositories/{repository_key}", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500], + bearer_auth = true, + browser_auth = true + )] + async fn repository_detail( + &self, + #[path] workspace_id: String, + #[path] repository_key: String, + ) -> Result; + + #[get( + "/api/repositories/{repository_key}", + status = 200, + error_status = 404, + additional_error_statuses = [400, 401, 403, 500], + bearer_auth = true, + browser_auth = true + )] + async fn repository_detail_alias( + &self, + #[path] repository_key: String, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/repositories", + status = 201, + alternate_status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 413, 415, 422, 500], + bearer_auth = true, + browser_auth = true, + normalize_body_errors = true + )] + async fn repository_create( + &self, + #[extension] actor: RequestActor, + #[path] workspace_id: String, + #[body] request: CreateWorkspaceRepositoryRequest, + ) -> Result; +} + +/// Digest of the fully rendered canonical contract with its digest slot normalized. +pub fn canonical_openapi_source_digest() -> Result { + const NORMALIZED_DIGEST: &str = + "sha256:0000000000000000000000000000000000000000000000000000000000000000"; + let normalized = server_api_openapi(api_macros::openapi::OpenApiInfo { + title: "Yoi Server API", + version: env!("CARGO_PKG_VERSION"), + source_digest: NORMALIZED_DIGEST, + })? + .to_json()?; + let digest = Sha256::digest(normalized.as_bytes()); + let mut encoded = String::with_capacity("sha256:".len() + digest.len() * 2); + encoded.push_str("sha256:"); + for byte in digest { + use std::fmt::Write as _; + write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); + } + Ok(encoded) +} + +/// Build the deployment-independent canonical ServerApi OpenAPI document. +pub fn canonical_openapi_document() +-> Result { + let source_digest = canonical_openapi_source_digest()?; + server_api_openapi(api_macros::openapi::OpenApiInfo { + title: "Yoi Server API", + version: env!("CARGO_PKG_VERSION"), + source_digest: &source_digest, + }) +} + /// Public browser-authentication configuration. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] @@ -365,7 +559,7 @@ pub fn validate_repository_key(value: &str) -> Result<(), RepositoryKeyError> { /// /// Local paths remain distinct from network Git transports so callers cannot /// accidentally treat an unmaterialized remote as a server-local filesystem path. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum RepositorySourceKind { @@ -422,7 +616,7 @@ impl<'de> Deserialize<'de> for RepositorySourceKind { } /// Stable Repository source identity stored by Workspace authority. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] pub struct RepositorySource { pub kind: RepositorySourceKind, @@ -435,7 +629,7 @@ pub struct RepositorySource { /// /// The Server parses and canonicalizes `source`; callers cannot assert a /// transport classification or supply credential material through this DTO. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct CreateWorkspaceRepositoryRequest { pub repository_key: String, @@ -444,14 +638,20 @@ pub struct CreateWorkspaceRepositoryRequest { pub default_ref: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] pub struct CreateWorkspaceRepositoryResponse { pub workspace_id: String, pub repository_key: String, pub replayed: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +impl api_macros::HttpSuccess for CreateWorkspaceRepositoryResponse { + fn status_code(&self) -> u16 { + if self.replayed { 200 } else { 201 } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum RepositoryObservedStatus { @@ -1096,7 +1296,7 @@ pub enum WorkspaceProfileSourceProvenance { ProjectProfileSourceTree, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct RepositoryDiagnostic { @@ -1105,7 +1305,7 @@ pub struct RepositoryDiagnostic { pub message: String, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct GitRemoteSummary { @@ -1113,7 +1313,7 @@ pub struct GitRemoteSummary { pub fetch_url: String, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct GitRepositorySummary { @@ -1124,7 +1324,7 @@ pub struct GitRepositorySummary { pub remotes: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct RepositorySummary { @@ -1133,6 +1333,7 @@ pub struct RepositorySummary { pub provider: String, pub source: RepositorySource, #[cfg_attr(feature = "typescript", ts(type = "number"))] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub source_revision: u64, pub source_fingerprint: String, pub observed_status: RepositoryObservedStatus, @@ -1165,7 +1366,7 @@ pub struct GitCommitSummary { pub refs: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct RepositoryListResponse { @@ -1175,7 +1376,7 @@ pub struct RepositoryListResponse { pub diagnostics: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct RepositoryDetailResponse { @@ -1253,7 +1454,7 @@ pub struct RepositoryLogResponse { pub const TICKET_RELATIONS_QUERY_PATH: &str = "/tickets/relations/search"; pub const TICKET_ORCHESTRATION_PLANS_QUERY_PATH: &str = "/tickets/orchestration-plans/search"; -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] pub enum DiagnosticSeverity { @@ -1262,7 +1463,7 @@ pub enum DiagnosticSeverity { Error, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct Diagnostic { @@ -1451,6 +1652,106 @@ impl WorkingDirectorySummary { self.cleanup_target.is_some() } } +impl From + for WorkingDirectoryMaterializerKind +{ + fn from(value: workdir::workspace::WorkingDirectoryMaterializerKind) -> Self { + match value { + workdir::workspace::WorkingDirectoryMaterializerKind::RuntimeGitClone => { + Self::RuntimeGitClone + } + } + } +} + +impl From for WorkingDirectoryStatusKind { + fn from(value: workdir::workspace::WorkingDirectoryStatusKind) -> Self { + match value { + workdir::workspace::WorkingDirectoryStatusKind::Active => Self::Active, + workdir::workspace::WorkingDirectoryStatusKind::CleanupPending => Self::CleanupPending, + workdir::workspace::WorkingDirectoryStatusKind::Corrupted => Self::Corrupted, + workdir::workspace::WorkingDirectoryStatusKind::NotFound => Self::NotFound, + workdir::workspace::WorkingDirectoryStatusKind::Unknown => Self::Unknown, + } + } +} + +impl From for WorkingDirectoryCleanupTarget { + fn from(value: workdir::workspace::WorkingDirectoryCleanupTarget) -> Self { + Self { + kind: value.kind, + working_directory_id: value.working_directory_id, + repository_key: value.repository_key, + } + } +} + +impl From for WorkingDirectoryOccupancy { + fn from(value: workdir::workspace::WorkingDirectoryOccupancy) -> Self { + Self { + runtime_id: value.runtime_id, + worker_id: value.worker_id, + display_name: value.display_name, + linked_at: value.linked_at, + } + } +} + +impl From + for RuntimeWorkingDirectoryCleanupTarget +{ + fn from(value: workdir::workspace::RuntimeWorkingDirectoryCleanupTarget) -> Self { + Self { + kind: value.kind, + working_directory_id: value.working_directory_id, + repository_id: value.repository_id, + } + } +} + +impl From for RuntimeWorkingDirectorySummary { + fn from(value: workdir::workspace::RuntimeWorkingDirectorySummary) -> Self { + Self { + working_directory_id: value.working_directory_id, + repository_id: value.repository_id, + creation_selector: value.creation_selector, + creation_ref: value.creation_ref, + creation_tree: value.creation_tree, + current_selector: value.current_selector, + current_ref: value.current_ref, + current_tree: value.current_tree, + observed_at_epoch_seconds: value.observed_at_epoch_seconds, + materializer_kind: value.materializer_kind.into(), + cleanup_target: value.cleanup_target.map(Into::into), + status: value.status.into(), + cleanliness: value.cleanliness, + primary_worker_id: value.primary_worker_id, + occupied_by: value.occupied_by.map(Into::into), + } + } +} + +impl From for WorkingDirectorySummary { + fn from(value: workdir::workspace::WorkingDirectorySummary) -> Self { + Self { + working_directory_id: value.working_directory_id, + repository_key: value.repository_key, + creation_selector: value.creation_selector, + creation_ref: value.creation_ref, + creation_tree: value.creation_tree, + current_selector: value.current_selector, + current_ref: value.current_ref, + current_tree: value.current_tree, + observed_at_epoch_seconds: value.observed_at_epoch_seconds, + materializer_kind: value.materializer_kind.into(), + cleanup_target: value.cleanup_target.map(Into::into), + status: value.status.into(), + cleanliness: value.cleanliness, + primary_worker_id: value.primary_worker_id, + occupied_by: value.occupied_by.map(Into::into), + } + } +} /// Browser/Rust-client Workdir materialization request. /// @@ -2128,7 +2429,7 @@ pub fn companion_api_typescript() -> String { ]; format!( - "// Generated by `cargo run -p workspace-api --features typescript --example generate_companion_api_types`.\n// Do not edit manually.\n\n{}\n", + "// Generated by `cargo run -p server-api --features typescript --example generate_companion_api_types`.\n// Do not edit manually.\n\n{}\n", declarations .into_iter() .map(|declaration| format!("export {declaration}")) @@ -2327,15 +2628,36 @@ pub enum WorkerOperationState { Rejected, } +/// Cross-layer outcome of an explicit Worker restore operation. +/// +/// `Rejected` is reserved for read-only preflight failures. Once live restore +/// work starts, failure is either a confirmed `RolledBack` operation or a +/// `ReconciliationRequired` result whose commit state must be reread/retried. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum WorkerRestoreState { + Accepted, + Rejected, + RolledBack, + ReconciliationRequired, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] pub struct WorkerRestoreResult { - pub state: WorkerOperationState, + pub state: WorkerRestoreState, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] pub worker: Option, #[serde(default)] pub diagnostics: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] pub struct WorkerRestoreResponse { pub workspace_id: String, pub runtime_id: String, @@ -2525,7 +2847,7 @@ pub fn memory_api_typescript() -> String { ]; format!( - "// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{}\n", + "// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{}\n", declarations .into_iter() .map(|declaration| format!("export {declaration}")) @@ -3103,7 +3425,18 @@ fn validate_diagnostics(diagnostics: &[SkillDiagnostic]) -> Result<(), SkillApiV } #[cfg(feature = "typescript")] -pub fn catalog_typescript() -> String { +pub fn normalized_typescript(generated: String) -> String { + let mut normalized = generated + .lines() + .map(str::trim_end) + .collect::>() + .join("\n"); + normalized.push('\n'); + normalized +} + +#[cfg(feature = "typescript")] +pub fn legacy_catalog_typescript() -> String { use ts_rs::TS; let config = ts_rs::Config::default(); @@ -3137,16 +3470,7 @@ pub fn catalog_typescript() -> String { WorkspaceProfileSummary::decl(&config), WorkspaceProfileSourceSummary::decl(&config), WorkspaceProfileSourceProvenance::decl(&config), - RepositorySourceKind::decl(&config), - RepositorySource::decl(&config), - RepositoryObservedStatus::decl(&config), - RepositoryDiagnostic::decl(&config), - GitRemoteSummary::decl(&config), - GitRepositorySummary::decl(&config), - RepositorySummary::decl(&config), GitCommitSummary::decl(&config), - RepositoryListResponse::decl(&config), - RepositoryDetailResponse::decl(&config), RepositorySshConnectionProbeRequest::decl(&config), RepositorySshHostKeyCandidate::decl(&config), RepositorySshConnectionTrustState::decl(&config), @@ -3187,7 +3511,7 @@ pub fn catalog_typescript() -> String { .map(|declaration| format!("export {declaration}")); format!( - "// This file is generated by `cargo run -p workspace-api --features typescript --example generate_typescript | deno fmt -`.\n// Do not edit this file directly.\n\n{}\n", + "// This file is generated by `cargo run -p server-api --features typescript --example generate_legacy_typescript | deno fmt -`.\n// Do not edit this file directly.\n\nimport type {{ RepositoryObservedStatus, RepositorySource }} from \"./repository-api\";\n\n{}\n", declarations.join("\n\n") ) } @@ -3212,7 +3536,7 @@ pub fn repository_access_api_typescript() -> String { RepositoryAccessProjection::decl(&config), ]; format!( - "// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts\n\n{}\n", + "// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts\n\n{}\n", declarations .into_iter() .map(|declaration| format!("export {declaration}")) @@ -3243,7 +3567,7 @@ pub fn skill_api_typescript() -> String { "export const SKILL_API_AUTHORITY = \"{SKILL_CATALOG_AUTHORITY}\" as const;\n\nexport const SKILL_API_LIMITS = {{\n maxSafeInteger: {SKILL_API_MAX_SAFE_INTEGER},\n maxCatalogEntries: {SKILL_API_MAX_CATALOG_ENTRIES},\n maxOverrides: {SKILL_API_MAX_OVERRIDES},\n maxDiagnostics: {SKILL_API_MAX_DIAGNOSTICS},\n maxResources: {SKILL_API_MAX_RESOURCES},\n maxAllowedTools: {SKILL_API_MAX_ALLOWED_TOOLS},\n maxNameBytes: {SKILL_API_MAX_NAME_BYTES},\n maxLabelBytes: {SKILL_API_MAX_LABEL_BYTES},\n maxBodyBytes: {SKILL_API_MAX_BODY_BYTES},\n maxPathBytes: {SKILL_API_MAX_PATH_BYTES},\n maxDigestBytes: {SKILL_API_MAX_DIGEST_BYTES},\n maxResponseBytes: {SKILL_API_MAX_RESPONSE_BYTES},\n}} as const;" ); format!( - "// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_skill_api_types > web/workspace/src/lib/generated/skill-api.ts\n\n{limits}\n\n{}\n", + "// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_skill_api_types > web/workspace/src/lib/generated/skill-api.ts\n\n{limits}\n\n{}\n", declarations .into_iter() .map(|declaration| format!("export {declaration}")) @@ -3284,7 +3608,7 @@ pub fn auth_api_typescript() -> String { LogoutResponse::decl(&config), ]; format!( - "// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_auth_api_types > web/workspace/src/lib/generated/auth-api.ts\n\n{}\n", + "// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_auth_api_types > web/workspace/src/lib/generated/auth-api.ts\n\n{}\n", declarations .into_iter() .map(|declaration| format!("export {declaration}")) @@ -3312,7 +3636,7 @@ pub fn workdir_api_typescript() -> String { WorkingDirectoryCreateResponse::decl(&config), ]; format!( - "// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts\n\n{}\n", + "// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts\n\n{}\n", declarations .into_iter() .map(|declaration| format!("export {declaration}")) @@ -3351,7 +3675,7 @@ pub fn worker_launch_api_typescript() -> String { BrowserWorkspaceOrchestratorResponse::decl(&config), ]; format!( - "// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_worker_launch_api_types > web/workspace/src/lib/generated/worker-launch-api.ts\n\nimport type {{ Segment }} from \"./protocol\";\n\n{}\n", + "// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_worker_launch_api_types > web/workspace/src/lib/generated/worker-launch-api.ts\n\nimport type {{ Segment }} from \"./protocol\";\n\n{}\n", declarations .into_iter() .map(|declaration| format!("export {declaration}")) @@ -3372,7 +3696,7 @@ mod worker_launch_typescript_tests { assert_eq!( normalize(&actual), normalize(&expected), - "regenerate Worker launch API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_worker_launch_api_types > web/workspace/src/lib/generated/worker-launch-api.ts` and format the generated file", + "regenerate Worker launch API TypeScript types with `cargo run -q -p server-api --features typescript --example generate_worker_launch_api_types > web/workspace/src/lib/generated/worker-launch-api.ts` and format the generated file", ); } @@ -3401,7 +3725,7 @@ mod memory_typescript_tests { assert_eq!( normalize(&actual), normalize(&expected), - "regenerate Memory API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts` and format the generated file", + "regenerate Memory API TypeScript types with `cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts` and format the generated file", ); } @@ -3431,7 +3755,7 @@ mod skill_typescript_tests { assert_eq!( normalize(&actual), normalize(&expected), - "regenerate Skill API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_skill_api_types > web/workspace/src/lib/generated/skill-api.ts` and format the generated file", + "regenerate Skill API TypeScript types with `cargo run -q -p server-api --features typescript --example generate_skill_api_types > web/workspace/src/lib/generated/skill-api.ts` and format the generated file", ); } @@ -3461,7 +3785,7 @@ mod workdir_typescript_tests { assert_eq!( normalize(&actual), normalize(&expected), - "regenerate Workdir API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts` and format the generated file", + "regenerate Workdir API TypeScript types with `cargo run -q -p server-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts` and format the generated file", ); } @@ -3481,6 +3805,67 @@ mod workdir_typescript_tests { #[cfg(test)] mod tests { use super::*; + #[test] + fn worker_session_contract_and_flattened_availability_are_stable() { + let operations = ServerApiMetadata::OPERATIONS; + assert_eq!(operations.len(), 6); + let operation = operations + .iter() + .find(|operation| operation.operation_id == "worker_session") + .expect("worker-session operation must remain in ServerApi metadata"); + assert_eq!(operation.method, HttpMethod::Get); + assert_eq!( + operation.path, + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session" + ); + + let response = WorkspaceWorkerSessionResponse { + subject: WorkspaceWorkerSubject::RuntimeWorker { + runtime_id: "runtime-a".to_string(), + worker_id: "worker-a".to_string(), + }, + observation: runtime_api::WorkerSessionAvailability::LiveProtocol, + }; + let value = serde_json::to_value(response).unwrap(); + assert_eq!(value["availability"], "live_protocol"); + assert_eq!(value["subject"]["kind"], "runtime_worker"); + } + + #[test] + fn worker_restore_state_round_trips_and_rejects_unknown_variants() { + for (state, wire) in [ + (WorkerRestoreState::Accepted, "accepted"), + (WorkerRestoreState::Rejected, "rejected"), + (WorkerRestoreState::RolledBack, "rolled_back"), + ( + WorkerRestoreState::ReconciliationRequired, + "reconciliation_required", + ), + ] { + assert_eq!( + serde_json::to_value(state).unwrap(), + serde_json::json!(wire) + ); + assert_eq!( + serde_json::from_value::(serde_json::json!(wire)).unwrap(), + state + ); + } + assert!( + serde_json::from_value::(serde_json::json!("unknown")).is_err() + ); + } + + #[test] + fn worker_restore_result_rejects_unknown_fields() { + let result = serde_json::from_value::(serde_json::json!({ + "state": "rejected", + "worker": null, + "diagnostics": [], + "unexpected": true + })); + assert!(result.is_err()); + } #[test] fn historical_http_repository_source_kind_decodes_as_invalid_evidence() { @@ -4122,15 +4507,20 @@ mod tests { #[cfg(feature = "typescript")] #[test] fn generated_catalog_typescript_keeps_public_wrappers_and_nullability() { - let output = catalog_typescript(); + let output = legacy_catalog_typescript(); assert!( output.contains("export type WorkspaceCatalogListResponse = Array") ); assert!(output.contains("export type WorkspaceResponse =")); assert!(output.contains("permissions: WorkspacePermissionSummary")); - assert!(output.contains("export type RepositoryListResponse =")); - assert!(output.contains("items: Array")); - assert!(output.contains("observed_at?: string | null")); + assert!(output.contains( + "import type { RepositoryObservedStatus, RepositorySource } from \"./repository-api\";" + )); + assert!(!output.contains("export type RepositoryListResponse =")); + assert!(!output.contains("export type RepositoryDetailResponse =")); + assert!(!output.contains("export type RepositorySummary =")); + assert!(!output.contains("export type RepositorySource =")); + assert!(!output.contains("export type RepositoryObservedStatus =")); assert!(output.contains("export type WorkspaceMetadataSettingsResponse =")); assert!(output.contains("export type WorkspaceMetadataMutationResponse =")); assert!(output.contains("export type ProfileSettingsResponse =")); @@ -4572,7 +4962,7 @@ mod tests { assert_eq!( normalize_typescript(&actual), normalize_typescript(&expected), - "regenerate auth API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_auth_api_types > web/workspace/src/lib/generated/auth-api.ts` and format the generated file", + "regenerate auth API TypeScript types with `cargo run -q -p server-api --features typescript --example generate_auth_api_types > web/workspace/src/lib/generated/auth-api.ts` and format the generated file", ); } @@ -4655,7 +5045,7 @@ mod tests { assert_eq!( normalize_typescript(&actual), normalize_typescript(&expected), - "regenerate Companion API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_companion_api_types > web/workspace/src/lib/generated/companion-api.ts` and format the generated file", + "regenerate Companion API TypeScript types with `cargo run -q -p server-api --features typescript --example generate_companion_api_types > web/workspace/src/lib/generated/companion-api.ts` and format the generated file", ); } @@ -4767,6 +5157,107 @@ mod tests { } } +#[cfg(test)] +mod openapi_artifact_tests { + use super::*; + + #[test] + fn canonical_openapi_artifact_is_current() { + let generated = canonical_openapi_document() + .expect("canonical OpenAPI contract must be valid") + .to_json() + .expect("canonical OpenAPI contract must serialize"); + let checked_in = include_str!("../../../openapi/server-api.json"); + assert_eq!( + generated, checked_in, + "regenerate with `cargo run -p server-api --example export_openapi -- openapi/server-api.json`", + ); + } + + #[test] + fn repository_operations_are_in_the_canonical_contract() { + let document = canonical_openapi_document().expect("canonical OpenAPI contract must build"); + let value: serde_json::Value = + serde_json::from_str(&document.to_json().expect("document must serialize")) + .expect("document must be JSON"); + + assert_eq!(value["openapi"], "3.1.0"); + assert!( + value["paths"] + ["/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session"] + .is_null() + ); + assert!( + value["info"]["x-yoi-source-digest"] + .as_str() + .is_some_and(|digest| digest.starts_with("sha256:")) + ); + assert_eq!( + value["paths"]["/api/w/{workspace_id}/repositories"]["get"]["responses"]["200"]["content"] + ["application/json"]["schema"]["$ref"], + "#/components/schemas/RepositoryListResponse" + ); + assert!(value["paths"]["/api/repositories"]["get"].is_object()); + assert!(value["paths"]["/api/repositories/{repository_key}"]["get"].is_object()); + for (path, method) in [ + ("/api/w/{workspace_id}/repositories", "get"), + ("/api/w/{workspace_id}/repositories", "post"), + ("/api/w/{workspace_id}/repositories/{repository_key}", "get"), + ("/api/repositories", "get"), + ("/api/repositories/{repository_key}", "get"), + ] { + let operation = &value["paths"][path][method]; + assert_eq!( + operation["security"][0]["bearerAuth"], + serde_json::json!([]) + ); + assert_eq!( + operation["security"][1]["browserSession"], + serde_json::json!([]) + ); + for status in ["401", "403", "500"] { + assert!( + operation["responses"][status].is_object(), + "missing {method} {path} response {status}" + ); + } + } + let repository_collection = &value["paths"]["/api/w/{workspace_id}/repositories"]; + let repository_create = &repository_collection["post"]; + assert_eq!( + repository_create["security"][0]["bearerAuth"], + serde_json::json!([]) + ); + assert_eq!( + repository_create["security"][1]["browserSession"], + serde_json::json!([]) + ); + for status in [ + "200", "201", "400", "401", "403", "404", "409", "413", "415", "422", "500", + ] { + assert!( + repository_create["responses"][status].is_object(), + "missing repository-create response {status}" + ); + } + assert_eq!( + repository_create["responses"]["422"]["content"]["application/json"]["schema"]["$ref"], + "#/components/schemas/RepositoryApiError" + ); + assert_eq!( + value["components"]["securitySchemes"]["bearerAuth"]["scheme"], + "bearer" + ); + assert_eq!( + value["components"]["securitySchemes"]["browserSession"]["in"], + "cookie" + ); + assert!(value["paths"]["/api/w/{workspace_id}/repositories/{repository_key}"] + ["get"]["responses"]["404"] + .is_object()); + } +} + #[cfg(all(test, feature = "typescript"))] mod typescript_tests { #[test] @@ -4779,7 +5270,7 @@ mod typescript_tests { assert_eq!( normalize(&actual), normalize(&expected), - "regenerate Repository Access TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts` and format the generated file", + "regenerate Repository Access TypeScript types with `cargo run -q -p server-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts` and format the generated file", ); } diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs new file mode 100644 index 000000000..ac459aa91 --- /dev/null +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -0,0 +1,852 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{Map, Value}; + +pub const GENERATOR_NAME: &str = "yoi-repository-openapi-typescript"; +pub const GENERATOR_VERSION: &str = "1"; +pub const GENERATOR_OPTIONS: &str = "repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output"; +pub const INPUT_PATH: &str = "openapi/server-api.json"; +pub const OUTPUT_PATH: &str = "web/workspace/src/lib/generated/repository-api.ts"; + +const MAX_SAFE_INTEGER: f64 = 9_007_199_254_740_991.0; +const MIN_SAFE_INTEGER: f64 = -9_007_199_254_740_991.0; +const SCHEMA_REF_PREFIX: &str = "#/components/schemas/"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GenerationError(String); + +impl GenerationError { + fn invalid(message: impl Into) -> Self { + Self(message.into()) + } +} + +impl std::fmt::Display for GenerationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.0) + } +} + +impl std::error::Error for GenerationError {} + +pub fn generate_repository_typescript(input: &str) -> Result { + let document: Value = serde_json::from_str(input) + .map_err(|error| GenerationError::invalid(format!("invalid OpenAPI JSON: {error}")))?; + let root = object(&document, "OpenAPI document")?; + if root.get("openapi").and_then(Value::as_str) != Some("3.1.0") { + return Err(GenerationError::invalid( + "Repository TypeScript generation requires OpenAPI 3.1.0", + )); + } + + let info = object_field(root, "info", "OpenAPI document")?; + let contract_version = required_string(info, "version", "OpenAPI info")?; + let contract_digest = required_string(info, "x-yoi-source-digest", "OpenAPI info")?; + validate_contract_digest(contract_digest)?; + + let schemas = object_field( + object_field(root, "components", "OpenAPI document")?, + "schemas", + "OpenAPI components", + )?; + let paths = object_field(root, "paths", "OpenAPI document")?; + let roots = validate_repository_operations(paths)?; + let closure = schema_closure(schemas, &roots)?; + + let mut output = format!( + "// @generated by {GENERATOR_NAME}@{GENERATOR_VERSION}. Do not edit.\n\ +// Input: {INPUT_PATH}\n\ +// Input contract: {contract_version} ({contract_digest})\n\ +// Options: {GENERATOR_OPTIONS}\n\ +// Output: {OUTPUT_PATH}\n\ +// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check\n\n" + ); + for name in closure { + let schema = schemas + .get(&name) + .ok_or_else(|| GenerationError::invalid(format!("missing schema `{name}`")))?; + let declaration = render_schema(schema, &format!("schema `{name}`"))?; + output.push_str(&format!("export type {name} = {declaration};\n\n")); + } + output.pop(); + Ok(output) +} + +fn validate_contract_digest(digest: &str) -> Result<(), GenerationError> { + let Some(hex) = digest.strip_prefix("sha256:") else { + return Err(GenerationError::invalid( + "OpenAPI source digest must use sha256", + )); + }; + if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(GenerationError::invalid( + "OpenAPI source digest must contain 64 hexadecimal digits", + )); + } + Ok(()) +} + +fn validate_repository_operations( + paths: &Map, +) -> Result, GenerationError> { + let mut roots = BTreeSet::new(); + for operation in [ + OperationContract { + path: "/api/repositories", + method: "get", + operation_id: "repository_list_alias", + request: None, + successes: &[("200", "RepositoryListResponse")], + }, + OperationContract { + path: "/api/repositories/{repository_key}", + method: "get", + operation_id: "repository_detail_alias", + request: None, + successes: &[("200", "RepositoryDetailResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/repositories", + method: "get", + operation_id: "repository_list", + request: None, + successes: &[("200", "RepositoryListResponse")], + }, + OperationContract { + path: "/api/w/{workspace_id}/repositories", + method: "post", + operation_id: "repository_create", + request: Some("CreateWorkspaceRepositoryRequest"), + successes: &[ + ("200", "CreateWorkspaceRepositoryResponse"), + ("201", "CreateWorkspaceRepositoryResponse"), + ], + }, + OperationContract { + path: "/api/w/{workspace_id}/repositories/{repository_key}", + method: "get", + operation_id: "repository_detail", + request: None, + successes: &[("200", "RepositoryDetailResponse")], + }, + ] { + let path = object_field(paths, operation.path, "OpenAPI paths")?; + let value = object_field(path, operation.method, operation.path)?; + if value.get("operationId").and_then(Value::as_str) != Some(operation.operation_id) { + return Err(GenerationError::invalid(format!( + "{} {} must retain operationId `{}`", + operation.method, operation.path, operation.operation_id + ))); + } + + match operation.request { + Some(expected) => { + let request = object_field(value, "requestBody", operation.operation_id)?; + let schema = json_content_schema(request, operation.operation_id)?; + require_schema_ref(schema, expected, operation.operation_id)?; + roots.insert(expected.to_owned()); + } + None if value.contains_key("requestBody") => { + return Err(GenerationError::invalid(format!( + "{} unexpectedly gained a request body", + operation.operation_id + ))); + } + None => {} + } + + let responses = object_field(value, "responses", operation.operation_id)?; + let successes = operation + .successes + .iter() + .copied() + .collect::>(); + for (status, response) in responses { + let response = object( + response, + &format!("{} response {status}", operation.operation_id), + )?; + let schema = json_content_schema(response, operation.operation_id)?; + let expected = successes + .get(status.as_str()) + .copied() + .unwrap_or("RepositoryApiError"); + require_schema_ref(schema, expected, operation.operation_id)?; + roots.insert(expected.to_owned()); + } + for (status, expected) in successes { + if !responses.contains_key(status) { + return Err(GenerationError::invalid(format!( + "{} is missing success response {status} ({expected})", + operation.operation_id + ))); + } + } + } + Ok(roots) +} + +struct OperationContract { + path: &'static str, + method: &'static str, + operation_id: &'static str, + request: Option<&'static str>, + successes: &'static [(&'static str, &'static str)], +} + +fn json_content_schema<'a>( + value: &'a Map, + context: &str, +) -> Result<&'a Value, GenerationError> { + object_field( + object_field(value, "content", context)?, + "application/json", + context, + )? + .get("schema") + .ok_or_else(|| GenerationError::invalid(format!("{context} is missing its JSON schema"))) +} + +fn schema_reference<'a>(value: &'a Value, context: &str) -> Result<&'a str, GenerationError> { + const ALLOWED_REFERENCE_SIBLINGS: &[&str] = + &["$ref", "$comment", "deprecated", "description", "title"]; + let schema = object(value, context)?; + if let Some(keyword) = schema + .keys() + .find(|keyword| !ALLOWED_REFERENCE_SIBLINGS.contains(&keyword.as_str())) + { + return Err(GenerationError::invalid(format!( + "{context} contains unsupported `$ref` sibling `{keyword}`" + ))); + } + schema.get("$ref").and_then(Value::as_str).ok_or_else(|| { + GenerationError::invalid(format!("{context} must use a string schema reference")) + }) +} + +fn require_schema_ref(value: &Value, expected: &str, context: &str) -> Result<(), GenerationError> { + let reference = schema_reference(value, context)?; + if reference != format!("{SCHEMA_REF_PREFIX}{expected}") { + return Err(GenerationError::invalid(format!( + "{context} must reference `{expected}`, found `{reference}`" + ))); + } + Ok(()) +} + +fn schema_closure( + schemas: &Map, + roots: &BTreeSet, +) -> Result, GenerationError> { + let mut closure = BTreeSet::new(); + let mut pending = roots.iter().cloned().collect::>(); + while let Some(name) = pending.pop() { + if !closure.insert(name.clone()) { + continue; + } + let schema = schemas.get(&name).ok_or_else(|| { + GenerationError::invalid(format!("OpenAPI schema `{name}` is missing")) + })?; + let mut references = BTreeSet::new(); + collect_schema_references(schema, &name, &mut references)?; + pending.extend(references); + } + Ok(closure) +} + +fn collect_schema_references( + value: &Value, + context: &str, + references: &mut BTreeSet, +) -> Result<(), GenerationError> { + match value { + Value::Object(object) => { + if object.contains_key("$ref") { + let reference = schema_reference(value, context)?; + let Some(name) = reference.strip_prefix(SCHEMA_REF_PREFIX) else { + return Err(GenerationError::invalid(format!( + "{context} contains unsupported reference `{reference}`" + ))); + }; + references.insert(name.to_owned()); + } + for value in object.values() { + collect_schema_references(value, context, references)?; + } + } + Value::Array(values) => { + for value in values { + collect_schema_references(value, context, references)?; + } + } + _ => {} + } + Ok(()) +} + +fn ensure_schema_keywords( + schema: &Map, + allowed: &[&str], + context: &str, +) -> Result<(), GenerationError> { + const ALLOWED_ANNOTATIONS: &[&str] = &[ + "$comment", + "default", + "deprecated", + "description", + "example", + "examples", + "title", + ]; + if let Some(keyword) = schema.keys().find(|keyword| { + !allowed.contains(&keyword.as_str()) && !ALLOWED_ANNOTATIONS.contains(&keyword.as_str()) + }) { + return Err(GenerationError::invalid(format!( + "{context} contains unsupported schema keyword `{keyword}`" + ))); + } + Ok(()) +} + +fn literal_matches_type(kind: &str, value: &Value) -> bool { + matches!( + (kind, value), + ("string", Value::String(_)) | ("boolean", Value::Bool(_)) | ("null", Value::Null) + ) +} + +fn literal_schema_matches_type(schema: &Map, kind: &str) -> bool { + if let Some(constant) = schema.get("const") { + return literal_matches_type(kind, constant); + } + schema + .get("enum") + .and_then(Value::as_array) + .is_some_and(|values| { + !values.is_empty() && values.iter().all(|value| literal_matches_type(kind, value)) + }) +} + +fn render_schema(value: &Value, context: &str) -> Result { + let schema = object(value, context)?; + if schema.contains_key("$ref") { + let reference = schema_reference(value, context)?; + return reference + .strip_prefix(SCHEMA_REF_PREFIX) + .map(str::to_owned) + .ok_or_else(|| { + GenerationError::invalid(format!( + "{context} contains unsupported reference `{reference}`" + )) + }); + } + if let Some(constant) = schema.get("const") { + ensure_schema_keywords(schema, &["const", "type"], context)?; + if let Some(type_value) = schema.get("type") { + let kind = type_value.as_str().ok_or_else(|| { + GenerationError::invalid(format!("{context} const type must be a string")) + })?; + if !literal_matches_type(kind, constant) { + return Err(GenerationError::invalid(format!( + "{context} const literal does not match type `{kind}`" + ))); + } + } + return render_literal(constant, context); + } + if let Some(enumeration) = schema.get("enum") { + ensure_schema_keywords(schema, &["enum", "type"], context)?; + let values = enumeration.as_array().ok_or_else(|| { + GenerationError::invalid(format!("{context} contains a non-array enum")) + })?; + if values.is_empty() { + return Err(GenerationError::invalid(format!( + "{context} contains an empty enum" + ))); + } + if let Some(type_value) = schema.get("type") { + let kind = type_value.as_str().ok_or_else(|| { + GenerationError::invalid(format!("{context} enum type must be a string")) + })?; + if !values.iter().all(|value| literal_matches_type(kind, value)) { + return Err(GenerationError::invalid(format!( + "{context} enum literals do not match type `{kind}`" + ))); + } + } + return values + .iter() + .map(|value| render_literal(value, context)) + .collect::, _>>() + .map(|values| values.join(" | ")); + } + if let Some(branches) = schema.get("anyOf") { + ensure_schema_keywords(schema, &["anyOf"], context)?; + return render_nullable_union(branches, context); + } + if let Some(branches) = schema.get("oneOf") { + ensure_schema_keywords(schema, &["oneOf", "type"], context)?; + let outer_type = match schema.get("type") { + Some(Value::String(kind)) if kind == "string" => Some(kind.as_str()), + Some(_) => { + return Err(GenerationError::invalid(format!( + "{context} contains an unsupported oneOf type" + ))); + } + None => None, + }; + let branches = branches.as_array().ok_or_else(|| { + GenerationError::invalid(format!("{context} contains a non-array oneOf")) + })?; + if branches.is_empty() { + return Err(GenerationError::invalid(format!( + "{context} contains an empty oneOf" + ))); + } + let mut rendered = Vec::with_capacity(branches.len()); + let mut seen_literals = BTreeSet::new(); + for branch in branches { + let branch_object = object(branch, context)?; + if !branch_object.contains_key("enum") && !branch_object.contains_key("const") { + return Err(GenerationError::invalid(format!( + "{context} contains unsupported ambiguous oneOf semantics" + ))); + } + let literals = if let Some(constant) = branch_object.get("const") { + std::slice::from_ref(constant) + } else { + branch_object + .get("enum") + .and_then(Value::as_array) + .ok_or_else(|| { + GenerationError::invalid(format!( + "{context} contains a non-array oneOf enum" + )) + })? + .as_slice() + }; + for literal in literals { + render_literal(literal, context)?; + let normalized = serde_json::to_string(literal).map_err(|error| { + GenerationError::invalid(format!( + "failed to normalize {context} oneOf literal: {error}" + )) + })?; + if !seen_literals.insert(normalized) { + return Err(GenerationError::invalid(format!( + "{context} contains overlapping oneOf literal branches" + ))); + } + } + if let Some(kind) = outer_type { + if !literal_schema_matches_type(branch_object, kind) { + return Err(GenerationError::invalid(format!( + "{context} oneOf branch does not match outer type `{kind}`" + ))); + } + } + rendered.push(render_schema(branch, context)?); + } + return Ok(rendered.join(" | ")); + } + + match schema.get("type") { + Some(Value::String(kind)) => render_typed_schema(kind, schema, context), + Some(Value::Array(kinds)) => { + if kinds.len() != 2 || !kinds.iter().any(|kind| kind.as_str() == Some("null")) { + return Err(GenerationError::invalid(format!( + "{context} contains an unsupported type union" + ))); + } + let kind = kinds + .iter() + .find_map(|kind| { + let kind = kind.as_str()?; + (kind != "null").then_some(kind) + }) + .ok_or_else(|| { + GenerationError::invalid(format!("{context} has no non-null type")) + })?; + Ok(format!( + "{} | null", + render_typed_schema(kind, schema, context)? + )) + } + _ => Err(GenerationError::invalid(format!( + "{context} has no supported type" + ))), + } +} + +fn render_nullable_union(value: &Value, context: &str) -> Result { + let branches = value + .as_array() + .ok_or_else(|| GenerationError::invalid(format!("{context} contains a non-array anyOf")))?; + if branches.len() != 2 + || branches + .iter() + .filter(|branch| branch.get("type").and_then(Value::as_str) == Some("null")) + .count() + != 1 + { + return Err(GenerationError::invalid(format!( + "{context} contains unsupported ambiguous anyOf semantics" + ))); + } + let null_branch = branches + .iter() + .find(|branch| branch.get("type").and_then(Value::as_str) == Some("null")) + .expect("validated one null branch"); + if render_schema(null_branch, context)? != "null" { + return Err(GenerationError::invalid(format!( + "{context} contains an invalid nullable null branch" + ))); + } + let branch = branches + .iter() + .find(|branch| branch.get("type").and_then(Value::as_str) != Some("null")) + .expect("validated one non-null branch"); + Ok(format!("{} | null", render_schema(branch, context)?)) +} + +fn render_typed_schema( + kind: &str, + schema: &Map, + context: &str, +) -> Result { + match kind { + "string" => { + ensure_schema_keywords(schema, &["type"], context)?; + Ok("string".to_owned()) + } + "boolean" => { + ensure_schema_keywords(schema, &["type"], context)?; + Ok("boolean".to_owned()) + } + "null" => { + ensure_schema_keywords(schema, &["type"], context)?; + Ok("null".to_owned()) + } + "integer" => { + ensure_schema_keywords(schema, &["format", "maximum", "minimum", "type"], context)?; + let format = match schema.get("format") { + Some(Value::String(format)) if format == "uint64" => Some(format.as_str()), + Some(Value::String(format)) => { + return Err(GenerationError::invalid(format!( + "{context} uses unsupported integer format `{format}`" + ))); + } + Some(_) => { + return Err(GenerationError::invalid(format!( + "{context} integer format must be a string" + ))); + } + None => None, + }; + let minimum = schema.get("minimum").and_then(Value::as_f64); + let maximum = schema.get("maximum").and_then(Value::as_f64); + let expected_minimum = if format == Some("uint64") { + 0.0 + } else { + MIN_SAFE_INTEGER + }; + if minimum != Some(expected_minimum) || maximum != Some(MAX_SAFE_INTEGER) { + return Err(GenerationError::invalid(format!( + "{context} integer range does not match its JavaScript safe-number mapping" + ))); + } + Ok("number".to_owned()) + } + "array" => { + ensure_schema_keywords(schema, &["items", "type"], context)?; + let items = schema.get("items").ok_or_else(|| { + GenerationError::invalid(format!("{context} array has no item schema")) + })?; + Ok(format!("Array<{}>", render_schema(items, context)?)) + } + "object" => { + ensure_schema_keywords( + schema, + &["additionalProperties", "properties", "required", "type"], + context, + )?; + render_object(schema, context) + } + other => Err(GenerationError::invalid(format!( + "{context} uses unsupported type `{other}`" + ))), + } +} + +fn render_object(schema: &Map, context: &str) -> Result { + let properties = schema + .get("properties") + .and_then(Value::as_object) + .ok_or_else(|| GenerationError::invalid(format!("{context} object has no properties")))?; + let required = match schema.get("required") { + Some(Value::Array(required)) => required + .iter() + .map(|name| { + name.as_str().map(str::to_owned).ok_or_else(|| { + GenerationError::invalid(format!( + "{context} contains a non-string required property" + )) + }) + }) + .collect::, _>>()?, + None => BTreeSet::new(), + Some(_) => { + return Err(GenerationError::invalid(format!( + "{context} contains a non-array required declaration" + ))); + } + }; + if let Some(missing) = required.iter().find(|name| !properties.contains_key(*name)) { + return Err(GenerationError::invalid(format!( + "{context} requires missing property `{missing}`" + ))); + } + if schema + .get("additionalProperties") + .is_some_and(|value| value != &Value::Bool(false)) + { + return Err(GenerationError::invalid(format!( + "{context} contains unsupported additionalProperties" + ))); + } + + let mut fields = Vec::with_capacity(properties.len()); + for (name, property) in properties { + let optional = if required.contains(name) { "" } else { "?" }; + let name = if is_typescript_identifier(name) { + name.clone() + } else { + serde_json::to_string(name).expect("serializing a property name cannot fail") + }; + fields.push(format!( + "{name}{optional}: {}", + render_schema(property, context)? + )); + } + Ok(format!("{{ {}; }}", fields.join("; "))) +} + +fn render_literal(value: &Value, context: &str) -> Result { + match value { + Value::String(_) | Value::Bool(_) | Value::Null => { + serde_json::to_string(value).map_err(|error| { + GenerationError::invalid(format!("failed to render {context} literal: {error}")) + }) + } + _ => Err(GenerationError::invalid(format!( + "{context} contains an unsupported non-string enum/const literal" + ))), + } +} + +fn is_typescript_identifier(value: &str) -> bool { + let mut bytes = value.bytes(); + let Some(first) = bytes.next() else { + return false; + }; + (first.is_ascii_alphabetic() || matches!(first, b'_' | b'$')) + && bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'$')) +} + +fn object<'a>(value: &'a Value, context: &str) -> Result<&'a Map, GenerationError> { + value + .as_object() + .ok_or_else(|| GenerationError::invalid(format!("{context} must be an object"))) +} + +fn object_field<'a>( + value: &'a Map, + field: &str, + context: &str, +) -> Result<&'a Map, GenerationError> { + value + .get(field) + .ok_or_else(|| GenerationError::invalid(format!("{context} is missing `{field}`"))) + .and_then(|value| object(value, &format!("{context}.{field}"))) +} + +fn required_string<'a>( + value: &'a Map, + field: &str, + context: &str, +) -> Result<&'a str, GenerationError> { + value + .get(field) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| GenerationError::invalid(format!("{context} requires string `{field}`"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + const OPENAPI: &str = include_str!("../../../openapi/server-api.json"); + const TYPESCRIPT: &str = + include_str!("../../../web/workspace/src/lib/generated/repository-api.ts"); + + #[test] + fn checked_in_repository_typescript_is_current() { + assert_eq!( + generate_repository_typescript(OPENAPI).unwrap(), + TYPESCRIPT, + "regenerate with `cargo run -q -p server-api --example generate_repository_openapi_types`", + ); + } + + #[test] + fn output_records_authority_and_preserves_openapi_nullability() { + let output = generate_repository_typescript(OPENAPI).unwrap(); + assert!(output.contains(&format!( + "@generated by {GENERATOR_NAME}@{GENERATOR_VERSION}" + ))); + assert!(output.contains("Input contract: 0.1.0 (sha256:")); + assert!(output.contains("export type RepositorySourceKind = \"local_path\" | \"file\" | \"ssh\" | \"https\" | \"invalid\"")); + assert!(output.contains("git?: GitRepositorySummary | null")); + assert!(output.contains("default_ref?: string | null")); + assert!(!output.contains("export type string =")); + } + + #[test] + fn generator_rejects_lossy_safe_integer_and_union_changes() { + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] = + serde_json::json!(9_007_199_254_740_992_u64); + let error = generate_repository_typescript(&document.to_string()).unwrap_err(); + assert!(error.to_string().contains("safe-number")); + + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["minimum"] = + serde_json::json!(-1); + let error = generate_repository_typescript(&document.to_string()).unwrap_err(); + assert!(error.to_string().contains("safe-number")); + + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["maximum"] = + serde_json::json!(9_007_199_254_740_990_u64); + let error = generate_repository_typescript(&document.to_string()).unwrap_err(); + assert!(error.to_string().contains("safe-number")); + + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["components"]["schemas"]["RepositorySummary"]["properties"]["source_revision"]["format"] = + serde_json::json!("int64"); + let error = generate_repository_typescript(&document.to_string()).unwrap_err(); + assert!(error.to_string().contains("unsupported integer format")); + + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["components"]["schemas"]["RepositorySummary"]["properties"]["git"]["anyOf"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({"type": "string"})); + let error = generate_repository_typescript(&document.to_string()).unwrap_err(); + assert!(error.to_string().contains("ambiguous anyOf")); + } + + #[test] + fn generator_rejects_lossy_reference_siblings() { + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["components"]["schemas"]["RepositorySummary"]["properties"]["source"]["maxLength"] = + serde_json::json!(1); + let error = generate_repository_typescript(&document.to_string()).unwrap_err(); + assert!(error.to_string().contains("unsupported `$ref` sibling")); + } + + #[test] + fn generator_rejects_lossy_operation_reference_siblings() { + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["paths"]["/api/repositories"]["get"]["responses"]["200"]["content"]["application/json"] + ["schema"]["maxItems"] = serde_json::json!(1); + let error = generate_repository_typescript(&document.to_string()).unwrap_err(); + assert!(error.to_string().contains("unsupported `$ref` sibling")); + } + + #[test] + fn generator_rejects_unimplemented_ordinary_schema_keywords() { + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["components"]["schemas"]["RepositorySource"]["properties"]["uri"]["maxLength"] = + serde_json::json!(1); + let error = generate_repository_typescript(&document.to_string()).unwrap_err(); + assert!( + error + .to_string() + .contains("unsupported schema keyword `maxLength`") + ); + + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["components"]["schemas"]["RepositoryListResponse"]["properties"]["items"]["maxItems"] = + serde_json::json!(1); + let error = generate_repository_typescript(&document.to_string()).unwrap_err(); + assert!( + error + .to_string() + .contains("unsupported schema keyword `maxItems`") + ); + } + + #[test] + fn generator_rejects_discarded_combined_schema_constraints() { + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["components"]["schemas"]["DiagnosticSeverity"]["const"] = + serde_json::json!("info"); + let error = generate_repository_typescript(&document.to_string()).unwrap_err(); + assert!( + error + .to_string() + .contains("unsupported schema keyword `enum`") + ); + + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + let null_branch = + document["components"]["schemas"]["RepositorySummary"]["properties"]["git"]["anyOf"] + .as_array_mut() + .unwrap() + .iter_mut() + .find(|branch| branch.get("type").and_then(Value::as_str) == Some("null")) + .unwrap(); + null_branch["maxLength"] = serde_json::json!(1); + let error = generate_repository_typescript(&document.to_string()).unwrap_err(); + assert!( + error + .to_string() + .contains("unsupported schema keyword `maxLength`") + ); + + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["components"]["schemas"]["RepositorySourceKind"]["type"] = + serde_json::json!("string"); + document["components"]["schemas"]["RepositorySourceKind"]["oneOf"][0] = + serde_json::json!({"const": true, "type": "boolean"}); + let error = generate_repository_typescript(&document.to_string()).unwrap_err(); + assert!( + error + .to_string() + .contains("oneOf branch does not match outer type") + ); + + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["components"]["schemas"]["RepositorySourceKind"]["oneOf"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({"const": "invalid", "type": "string"})); + let error = generate_repository_typescript(&document.to_string()).unwrap_err(); + assert!(error.to_string().contains("overlapping oneOf literal")); + } + + #[test] + fn generator_is_bounded_to_the_repository_operation_contract() { + let mut document: Value = serde_json::from_str(OPENAPI).unwrap(); + document["components"]["schemas"]["UnmigratedDomain"] = serde_json::json!({ + "type": "object", + "properties": {"pretend": {"type": "string"}}, + "required": ["pretend"], + "additionalProperties": false + }); + let output = generate_repository_typescript(&document.to_string()).unwrap(); + assert!(!output.contains("UnmigratedDomain")); + } +} diff --git a/crates/session-store/Cargo.toml b/crates/session-store/Cargo.toml index 5dd8b6886..cc697cef3 100644 --- a/crates/session-store/Cargo.toml +++ b/crates/session-store/Cargo.toml @@ -20,8 +20,12 @@ unicode-normalization = "0.1.25" unicode-properties = { version = "0.1.4", features = ["general-category"] } unicode-security = "0.1.2" +[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] +libc = "0.2" + [dev-dependencies] async-trait = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } tempfile = { workspace = true } +filetime = "0.2" futures = { workspace = true } diff --git a/crates/session-store/src/lib.rs b/crates/session-store/src/lib.rs index bf89e149b..1fb147da2 100644 --- a/crates/session-store/src/lib.rs +++ b/crates/session-store/src/lib.rs @@ -31,6 +31,36 @@ //! })?; //! ``` +use std::io; +use std::path::Path; + +/// Read an existing retained file without updating its access timestamp. +/// Observation fails closed when the platform cannot provide that guarantee. +pub(crate) fn read_without_atime(path: &Path) -> io::Result> { + #[cfg(any(target_os = "linux", target_os = "android"))] + { + use std::fs::OpenOptions; + use std::io::Read; + use std::os::unix::fs::OpenOptionsExt; + + let mut file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOATIME) + .open(path)?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + Ok(bytes) + } + #[cfg(not(any(target_os = "linux", target_os = "android")))] + { + let _ = path; + Err(io::Error::new( + io::ErrorKind::Unsupported, + "read-only retained observation requires no-atime file reads", + )) + } +} + pub mod event_trace; pub mod fs_store; pub mod history; @@ -57,6 +87,10 @@ pub use history::{ }; pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged}; pub use paste_artifact::PasteArtifactLimits; +pub use public_snapshot::{ + DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES, RetainedSessionIdentity, RetainedSessionSnapshot, + RetainedSnapshotReadError, project_session_snapshot, read_retained_session_snapshot, +}; pub use segment::{ SegmentStartState, append_entry, append_system_item, classify_logged_history_entry, create_compacted_segment, create_segment, create_segment_with_ids, ensure_head_or_fork, fork, diff --git a/crates/session-store/src/public_snapshot.rs b/crates/session-store/src/public_snapshot.rs index b17935842..752187064 100644 --- a/crates/session-store/src/public_snapshot.rs +++ b/crates/session-store/src/public_snapshot.rs @@ -7,11 +7,120 @@ use protocol::{ SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, }; +use std::path::Path; + +use thiserror::Error; + use crate::{ LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole, - LoggedSessionHistoryOrigin, SessionId, SystemItem, + LoggedSessionHistoryOrigin, SessionId, StoreError, SystemItem, WorkerAggregateStore, + WorkerSessionStore, WorkerStoreError, }; +/// Projection limit leaves one MiB for the typed API envelope so a public +/// retained-session response remains below the 16 MiB response ceiling. +pub const DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES: u64 = 15 * 1024 * 1024; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RetainedSessionIdentity { + pub session_id: String, + pub segment_id: String, + pub entry_count: u64, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct RetainedSessionSnapshot { + pub identity: RetainedSessionIdentity, + pub snapshot: SessionSnapshot, +} + +#[derive(Debug, Error)] +pub enum RetainedSnapshotReadError { + #[error("retained worker state is unavailable")] + RetentionMissing, + #[error("retained worker state has no active session pointer")] + ActivePointerMissing, + #[error("retained session requires migration")] + MigrationRequired, + #[error("retained session log is corrupt")] + CorruptLog, + #[error("retained session storage is unavailable")] + StorageUnavailable, + #[error("retained session snapshot exceeds the observation limit")] + SnapshotTooLarge, +} + +/// Read the active retained Session without creating, migrating, restoring, or +/// rewriting any persisted state. +pub fn read_retained_session_snapshot( + aggregate_root: &Path, + worker_name: &str, + max_bytes: u64, +) -> Result { + let aggregate = WorkerAggregateStore::open_read_only(aggregate_root, worker_name) + .map_err(map_worker_store_error)?; + let metadata = aggregate + .read_read_only() + .map_err(map_worker_store_error)? + .ok_or(RetainedSnapshotReadError::RetentionMissing)?; + let active = metadata + .active + .ok_or(RetainedSnapshotReadError::ActivePointerMissing)?; + let segment_id = active + .segment_id + .ok_or(RetainedSnapshotReadError::ActivePointerMissing)?; + let store = WorkerSessionStore::open_read_only(aggregate_root.join("session")) + .map_err(map_store_error)?; + if store.segment_log_len(segment_id).map_err(map_store_error)? > max_bytes { + return Err(RetainedSnapshotReadError::SnapshotTooLarge); + } + let entries = store + .read_all_read_only(active.session_id, segment_id) + .map_err(map_store_error)?; + let snapshot = project_session_snapshot(active.session_id, &entries); + if serde_json::to_vec(&snapshot) + .map_err(|_| RetainedSnapshotReadError::CorruptLog)? + .len() as u64 + > max_bytes + { + return Err(RetainedSnapshotReadError::SnapshotTooLarge); + } + Ok(RetainedSessionSnapshot { + identity: RetainedSessionIdentity { + session_id: active.session_id.to_string(), + segment_id: segment_id.to_string(), + entry_count: entries.len() as u64, + }, + snapshot, + }) +} + +fn map_worker_store_error(error: WorkerStoreError) -> RetainedSnapshotReadError { + match error { + WorkerStoreError::Io(error) if error.kind() == std::io::ErrorKind::NotFound => { + RetainedSnapshotReadError::RetentionMissing + } + WorkerStoreError::Serde(_) | WorkerStoreError::InvalidWorkerName(_) => { + RetainedSnapshotReadError::CorruptLog + } + WorkerStoreError::Io(_) => RetainedSnapshotReadError::StorageUnavailable, + } +} + +fn map_store_error(error: StoreError) -> RetainedSnapshotReadError { + match error { + StoreError::Io(error) if error.kind() == std::io::ErrorKind::NotFound => { + RetainedSnapshotReadError::RetentionMissing + } + StoreError::Corrupt { message, .. } if message.contains("requires migration") => { + RetainedSnapshotReadError::MigrationRequired + } + StoreError::Io(_) => RetainedSnapshotReadError::StorageUnavailable, + StoreError::Serde(_) | StoreError::Corrupt { .. } => RetainedSnapshotReadError::CorruptLog, + _ => RetainedSnapshotReadError::CorruptLog, + } +} + /// Project a complete current-segment log. A valid segment starts with one /// canonical annotated SegmentStart record; malformed partial input uses the /// nil session only to keep the public failure projection deterministic. @@ -318,7 +427,7 @@ mod tests { use super::*; use crate::{ LoggedHistoryDerivation, LoggedSessionHistoryEntryId, LoggedSessionHistoryMetadata, - LoggedWorkerSubject, + LoggedWorkerSubject, Store, WorkerMetadataStore, }; #[test] @@ -559,4 +668,89 @@ mod tests { SessionEntryProvenance::ModelOutput ); } + + #[test] + fn retained_snapshot_read_projects_active_segment_without_rewriting_files() { + let root = tempfile::tempdir().unwrap(); + let aggregate_root = root.path().join("worker-a"); + let aggregate = WorkerAggregateStore::new(&aggregate_root, "worker-a").unwrap(); + let session_id = crate::new_session_id(); + let segment_id = crate::new_segment_id(); + aggregate + .write(&crate::WorkerMetadata::new( + "worker-a", + Some(crate::WorkerActiveSegmentRef::active_segment( + session_id, segment_id, + )), + )) + .unwrap(); + let session = WorkerSessionStore::new(aggregate_root.join("session")).unwrap(); + session.create_segment(session_id, segment_id, &[]).unwrap(); + let metadata_path = aggregate_root.join("metadata.json"); + let manifest_path = aggregate_root.join("session/session.json"); + let segment_path = aggregate_root + .join("session/segments") + .join(format!("{segment_id}.jsonl")); + let metadata_before = std::fs::read(&metadata_path).unwrap(); + let manifest_before = std::fs::read(&manifest_path).unwrap(); + let old_atime = filetime::FileTime::from_unix_time(946_684_800, 0); + for path in [&metadata_path, &manifest_path, &segment_path] { + filetime::set_file_atime(path, old_atime).unwrap(); + } + let mtimes_before = [&metadata_path, &manifest_path, &segment_path].map(|path| { + filetime::FileTime::from_last_modification_time(&std::fs::metadata(path).unwrap()) + }); + + let retained = read_retained_session_snapshot( + &aggregate_root, + "worker-a", + DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES, + ) + .unwrap(); + + assert_eq!(retained.identity.session_id, session_id.to_string()); + assert_eq!(retained.identity.segment_id, segment_id.to_string()); + assert_eq!(retained.identity.entry_count, 0); + assert!(retained.snapshot.entries.is_empty()); + for (index, path) in [&metadata_path, &manifest_path, &segment_path] + .into_iter() + .enumerate() + { + let metadata = std::fs::metadata(path).unwrap(); + assert_eq!( + filetime::FileTime::from_last_access_time(&metadata), + old_atime, + "retained observation changed access time for {}", + path.display() + ); + assert_eq!( + filetime::FileTime::from_last_modification_time(&metadata), + mtimes_before[index], + "retained observation changed modification time for {}", + path.display() + ); + } + assert_eq!(std::fs::read(&metadata_path).unwrap(), metadata_before); + assert_eq!(std::fs::read(&manifest_path).unwrap(), manifest_before); + assert!(matches!( + read_retained_session_snapshot(&aggregate_root, "worker-a", 0), + Err(RetainedSnapshotReadError::SnapshotTooLarge) + )); + } + + #[test] + fn retained_snapshot_read_does_not_create_missing_aggregate() { + let root = tempfile::tempdir().unwrap(); + let aggregate = root.path().join("missing-worker"); + + let error = read_retained_session_snapshot( + &aggregate, + "missing-worker", + DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES, + ) + .unwrap_err(); + + assert!(matches!(error, RetainedSnapshotReadError::RetentionMissing)); + assert!(!aggregate.exists()); + } } diff --git a/crates/session-store/src/worker_metadata.rs b/crates/session-store/src/worker_metadata.rs index 4491f393e..f04a1245a 100644 --- a/crates/session-store/src/worker_metadata.rs +++ b/crates/session-store/src/worker_metadata.rs @@ -383,6 +383,43 @@ impl WorkerAggregateStore { }) } + /// Open an existing aggregate without creating or rewriting retained files. + pub fn open_read_only( + root: impl Into, + worker_name: impl Into, + ) -> Result { + let root = root.into(); + let worker_name = worker_name.into(); + validate_worker_name(&worker_name)?; + if !root.is_dir() { + return Err(WorkerStoreError::Io(std::io::Error::new( + std::io::ErrorKind::NotFound, + "worker aggregate is not retained", + ))); + } + Ok(Self { + update_lock: metadata_lock(&root), + root, + worker_name, + }) + } + + pub fn read_read_only(&self) -> Result, WorkerStoreError> { + let content = match crate::read_without_atime(&self.metadata_path()) { + Ok(content) => content, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + let metadata: WorkerMetadata = serde_json::from_slice(&content)?; + if metadata.worker_name != self.worker_name { + return Err(WorkerStoreError::InvalidWorkerName(format!( + "aggregate identity mismatch: expected `{}`, found `{}`", + self.worker_name, metadata.worker_name + ))); + } + Ok(Some(metadata)) + } + fn validate_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> { validate_worker_name(worker_name)?; if worker_name == self.worker_name { diff --git a/crates/session-store/src/worker_session_store.rs b/crates/session-store/src/worker_session_store.rs index ee00d508b..a30aedde2 100644 --- a/crates/session-store/src/worker_session_store.rs +++ b/crates/session-store/src/worker_session_store.rs @@ -84,6 +84,59 @@ impl WorkerSessionStore { }) } + /// Open an already-retained Session without creating directories or migrating + /// persisted data. Observation paths must never mutate retained state. + pub fn open_read_only(root: impl Into) -> Result { + let root = root.into(); + let bytes = crate::read_without_atime(&root.join(SESSION_FILE))?; + let manifest: SessionManifest = serde_json::from_slice(&bytes)?; + if manifest.schema_version != SESSION_SCHEMA_VERSION { + return Err(StoreError::Corrupt { + line: 0, + message: format!( + "Worker Session schema version {} requires migration; expected {}", + manifest.schema_version, SESSION_SCHEMA_VERSION + ), + }); + } + Ok(Self { + root, + session_id: Arc::new(Mutex::new(Some(manifest.session_id))), + append_lock: Arc::new(Mutex::new(())), + }) + } + + pub fn read_all_read_only( + &self, + session_id: SessionId, + segment_id: SegmentId, + ) -> Result, StoreError> { + let retained_session_id = self.session_id.lock().map_err(|_| StoreError::Corrupt { + line: 0, + message: "Worker Session identity lock poisoned".to_string(), + })?; + if *retained_session_id != Some(session_id) { + return Err(StoreError::Corrupt { + line: 0, + message: "active Worker Session identity does not match retained manifest" + .to_string(), + }); + } + let path = self.log_path(segment_id); + let bytes = crate::read_without_atime(&path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + StoreError::NotFound(segment_id) + } else { + StoreError::Io(error) + } + })?; + parse_jsonl(&bytes) + } + + pub fn segment_log_len(&self, segment_id: SegmentId) -> Result { + Ok(fs::metadata(self.log_path(segment_id))?.len()) + } + pub fn root_dir(&self) -> &Path { &self.root } diff --git a/crates/standalone/tests/host.rs b/crates/standalone/tests/host.rs index 45c8f620a..dfcaa9483 100644 --- a/crates/standalone/tests/host.rs +++ b/crates/standalone/tests/host.rs @@ -349,7 +349,6 @@ async fn standalone_restore_preserves_history_tasks_notifications_and_cwd_scope( .send(&Method::Notify { notification_request_id: protocol::new_submission_request_id(), message: "persisted notification".to_string(), - auto_run: true, }) .await?; wait_for_run_end(&mut protocol_client).await?; diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 18147a9b8..9a61c328b 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -27,6 +27,7 @@ session-store = { workspace = true } ticket = { workspace = true } serde = { workspace = true, features = ["derive"] } pulldown-cmark = { version = "0.13.3", default-features = false } +async-trait.workspace = true agen.workspace = true [dev-dependencies] diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index f9d4ba233..86cd7cbef 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -782,7 +782,6 @@ impl App { Some(Method::Notify { notification_request_id: protocol::new_submission_request_id(), message, - auto_run: true, }) } diff --git a/crates/tui/src/backend_worker_picker.rs b/crates/tui/src/backend_worker_picker.rs index ac7b2a7bb..1768334b2 100644 --- a/crates/tui/src/backend_worker_picker.rs +++ b/crates/tui/src/backend_worker_picker.rs @@ -3,9 +3,8 @@ use std::io; use std::time::Duration; use client::{ - BackendRuntimeListTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse, - BackendWorkerSummary, list_backend_stopped_workers, list_backend_workers, - restore_backend_worker, + BackendRuntimeListTarget, BackendWorkerSummary, WorkerSessionAvailability, + list_backend_stopped_workers, list_backend_workers, observe_backend_worker_session, }; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use ratatui::Frame; @@ -81,61 +80,22 @@ pub(crate) async fn run( } WorkerPickerResult::Selected(selected) => selected, }; - let worker = if selected.state == "stopped" { - let restore_target = target - .runtime_target(selected.runtime_id.clone(), selected.worker_id.clone()) - .map_err(|error| io::Error::other(error.to_string()))?; - let restore = restore_backend_worker(&restore_target) - .await - .map_err(|error| { - io::Error::other(format!( - "failed to restore Backend worker {}/{}: {error}", - selected.runtime_id, selected.worker_id - )) - })?; - restored_worker(restore).map_err(|error| { + let mut attach_target = target + .runtime_target(selected.runtime_id.clone(), selected.worker_id.clone()) + .map_err(|error| io::Error::other(error.to_string()))?; + let response = observe_backend_worker_session(&attach_target) + .await + .map_err(|error| { io::Error::other(format!( - "failed to restore Backend worker {}/{}: {error}", + "failed to observe Worker Session {}/{}: {error}", selected.runtime_id, selected.worker_id )) - })? - } else { - selected - }; - let attach_target = target - .runtime_target(worker.runtime_id, worker.worker_id) - .map_err(|error| io::Error::other(error.to_string()))?; + })?; + apply_worker_session_observation(&mut attach_target, response.observation); return console::run_backend_runtime(attach_target).await; } } -fn restored_worker(response: BackendWorkerRestoreResponse) -> Result { - if response.result.state != BackendWorkerOperationState::Accepted { - let diagnostics = response - .result - .diagnostics - .iter() - .map(|diagnostic| format!("{}: {}", diagnostic.code, diagnostic.message)) - .collect::>() - .join("; "); - let state = match response.result.state { - BackendWorkerOperationState::Accepted => unreachable!(), - BackendWorkerOperationState::Rejected => "rejected", - BackendWorkerOperationState::Unsupported => "unsupported", - }; - return Err(if diagnostics.is_empty() { - format!("restore was {state} without a diagnostic") - } else { - format!("restore was {state}: {diagnostics}") - }); - } - - response - .result - .worker - .ok_or_else(|| "restore was accepted without a Worker snapshot".to_string()) -} - fn dedup_workers(workers: &mut Vec) { let mut seen = std::collections::HashSet::new(); workers.retain(|worker| seen.insert((worker.runtime_id.clone(), worker.worker_id.clone()))); @@ -146,6 +106,28 @@ enum WorkerPickerResult { SwitchWorkspace, } +fn apply_worker_session_observation( + target: &mut client::BackendRuntimeTarget, + observation: WorkerSessionAvailability, +) { + match observation { + WorkerSessionAvailability::LiveProtocol => {} + WorkerSessionAvailability::RetainedSnapshot { snapshot, .. } => { + target.initial_snapshot = Some(snapshot); + target.initial_notice = Some("read-only retained Session snapshot".to_string()); + } + WorkerSessionAvailability::Unavailable { message, .. } => { + let bounded_message: String = message.chars().take(512).collect(); + target.initial_snapshot = Some(protocol::SessionSnapshot { + pending_submissions: protocol::PendingSubmissionsSnapshot::default(), + entries: Vec::new(), + }); + target.initial_notice = + Some(format!("retained Session unavailable: {bounded_message}")); + } + } +} + fn pick_worker( target: BackendRuntimeListTarget, mut workers: Vec, @@ -436,8 +418,7 @@ fn working_directory_text(worker: &BackendWorkerSummary) -> String { mod tests { use super::*; use client::{ - BackendDiagnostic, BackendDiagnosticSeverity, BackendWorkerCapabilitySummary, - BackendWorkerImplementationSummary, BackendWorkerRestoreResult, + BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendWorkerWorkspaceSummary, }; @@ -493,65 +474,51 @@ mod tests { text_width(&text[..byte_offset]) } - fn restore_response( - state: BackendWorkerOperationState, - worker: Option, - diagnostics: Vec, - ) -> BackendWorkerRestoreResponse { - BackendWorkerRestoreResponse { - workspace_id: "workspace-a".to_string(), - runtime_id: "runtime-a".to_string(), - worker_id: "worker-a".to_string(), - result: BackendWorkerRestoreResult { - state, - worker, - diagnostics, - }, - } - } - #[test] - fn rejected_restore_surfaces_diagnostic_instead_of_attaching_selected_worker() { - let error = restored_worker(restore_response( - BackendWorkerOperationState::Rejected, - None, - vec![BackendDiagnostic { - code: "working_directory_not_found".to_string(), - severity: BackendDiagnosticSeverity::Error, - message: "working directory was not found".to_string(), - }], - )) - .expect_err("rejected restore must not produce a Worker to attach"); - - assert_eq!( - error, - "restore was rejected: working_directory_not_found: working directory was not found" + fn authoritative_observation_selects_live_retained_and_unavailable_detail_modes() { + let mut live = client::BackendRuntimeTarget::new( + "http://127.0.0.1:3000", + "workspace-a", + "runtime-a", + "worker-a", ); - } + apply_worker_session_observation(&mut live, WorkerSessionAvailability::LiveProtocol); + assert!(live.initial_snapshot.is_none()); - #[test] - fn accepted_restore_requires_returned_worker_snapshot() { - let error = restored_worker(restore_response( - BackendWorkerOperationState::Accepted, - None, - Vec::new(), - )) - .expect_err("accepted restore without a Worker must not attach the stale selection"); + let snapshot = protocol::SessionSnapshot { + pending_submissions: protocol::PendingSubmissionsSnapshot::default(), + entries: Vec::new(), + }; + let mut retained = live.clone(); + apply_worker_session_observation( + &mut retained, + WorkerSessionAvailability::RetainedSnapshot { + identity: client::RetainedSessionIdentity { + session_id: "session-a".to_string(), + segment_id: "segment-a".to_string(), + entry_count: 0, + }, + snapshot: snapshot.clone(), + }, + ); + assert_eq!(retained.initial_snapshot, Some(snapshot)); + assert!(retained.initial_notice.unwrap().contains("read-only")); - assert_eq!(error, "restore was accepted without a Worker snapshot"); - } - - #[test] - fn accepted_restore_returns_authoritative_worker_snapshot() { - let worker = worker("runtime-a", "worker-a", Some("builtin:companion")); - let restored = restored_worker(restore_response( - BackendWorkerOperationState::Accepted, - Some(worker.clone()), - Vec::new(), - )) - .expect("accepted restore should return its Worker snapshot"); - - assert_eq!(restored, worker); + let mut unavailable = live; + apply_worker_session_observation( + &mut unavailable, + WorkerSessionAvailability::Unavailable { + reason: client::WorkerSessionUnavailableReason::RetentionMissing, + message: "retention expired".to_string(), + }, + ); + assert!(unavailable.initial_snapshot.is_some()); + assert!( + unavailable + .initial_notice + .unwrap() + .contains("retention expired") + ); } #[test] diff --git a/crates/tui/src/console/mod.rs b/crates/tui/src/console/mod.rs index af0410661..a3459b7c7 100644 --- a/crates/tui/src/console/mod.rs +++ b/crates/tui/src/console/mod.rs @@ -458,19 +458,83 @@ async fn run_standalone_host( pub(crate) async fn run_backend_runtime( target: BackendRuntimeTarget, ) -> Result<(), Box> { - let worker_label = target.display_label(); - let attachment_target = target.clone(); - let client = connect_backend_runtime(target).await?; + if let Some(snapshot) = target.initial_snapshot.clone() { + let client = Client::new(RetainedSnapshotSocket::new(snapshot)?); + return run_backend_console(target, client, false).await; + } + let client = connect_backend_runtime(target.clone()).await?; + run_backend_console(target, client, true).await +} + +async fn run_backend_console( + target: BackendRuntimeTarget, + client: Client, + connected: bool, +) -> Result<(), Box> { + let worker_label = match target.initial_notice.as_deref() { + Some(notice) => format!("{} — {notice}", target.display_label()), + None => target.display_label(), + }; let mut terminal = enter_fullscreen()?; let workspace_root = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); let mut app = App::new_with_persistent_input_history(worker_label, &workspace_root); - app.connected = true; - let mut connection = ConsoleConnection::with_backend_target(client, attachment_target); + app.connected = connected; + let mut connection = ConsoleConnection::with_backend_target(client, target); let result = run_loop(&mut terminal, &mut app, &mut connection).await; let _ = leave_fullscreen(&mut terminal); result } +struct RetainedSnapshotSocket { + event: Option, +} + +impl RetainedSnapshotSocket { + fn new(snapshot: protocol::SessionSnapshot) -> Result { + Ok(Self { + event: Some(serde_json::to_string(&Event::Snapshot { + session: snapshot, + greeting: protocol::Greeting { + worker_name: "retained worker".to_string(), + cwd: String::new(), + provider: "retained session".to_string(), + model: String::new(), + scope_summary: "read-only retained session".to_string(), + tools: Vec::new(), + context_window: 0, + context_tokens: 0, + }, + state: protocol::WorkerStateSnapshot { + last_command_id: 0, + state: protocol::WorkerState::Idle, + }, + in_flight: protocol::InFlightSnapshot::default(), + internal_workers: Vec::new(), + })?), + }) + } +} + +#[async_trait::async_trait] +impl Socket for RetainedSnapshotSocket { + type Error = std::convert::Infallible; + + async fn send(&mut self, _message: String) -> Result<(), Self::Error> { + Ok(()) + } + + async fn next(&mut self) -> Result, Self::Error> { + if self.event.is_some() { + return Ok(self.event.take()); + } + std::future::pending::, Self::Error>>().await + } + + fn try_next(&mut self) -> Result, Self::Error> { + Ok(self.event.take()) + } +} + fn enter_fullscreen() -> Result> { let mut stdout = io::stdout(); // Enable button-event tracking so the transcript can own drag selection; @@ -2009,7 +2073,6 @@ mod tests { method, Some(Method::Notify { ref message, - auto_run: true, .. }) if message == "progress" )); diff --git a/crates/workdir/Cargo.toml b/crates/workdir/Cargo.toml index 33f6c72b5..22c70a495 100644 --- a/crates/workdir/Cargo.toml +++ b/crates/workdir/Cargo.toml @@ -19,7 +19,6 @@ sha2.workspace = true tempfile.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["process", "rt", "sync", "time"] } -workspace-api = { workspace = true } [dev-dependencies] serde_json.workspace = true diff --git a/crates/workdir/src/workspace.rs b/crates/workdir/src/workspace.rs index fc696f991..76990f398 100644 --- a/crates/workdir/src/workspace.rs +++ b/crates/workdir/src/workspace.rs @@ -1,17 +1,139 @@ -//! Shared Workspace-facing Workdir inventory transport contracts. +//! Shared Workdir inventory domain contracts. //! -//! These projections describe Workspace inventory and durable occupancy. They -//! intentionally do not expose provider/session handles, host paths, Runtime -//! URLs, or credentials. Runtime-local Workdir operation contracts live in -//! [`crate::http`]. +//! These types are a neutral lower-level authority shared by Server and Runtime +//! transport projections. They intentionally do not expose provider/session +//! handles, host paths, Runtime URLs, or credentials. use serde::{Deserialize, Serialize}; -pub use workspace_api::{ - RuntimeWorkingDirectoryCleanupTarget, RuntimeWorkingDirectorySummary, - WorkingDirectoryCleanupTarget, WorkingDirectoryMaterializerKind as MaterializerKind, - WorkingDirectoryOccupancy, WorkingDirectoryStatusKind, WorkingDirectorySummary, -}; +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkingDirectoryMaterializerKind { + #[default] + RuntimeGitClone, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkingDirectoryStatusKind { + Active, + CleanupPending, + Corrupted, + NotFound, + Unknown, +} + +impl WorkingDirectoryStatusKind { + pub const fn as_str(&self) -> &'static str { + match self { + Self::Active => "active", + Self::CleanupPending => "cleanup_pending", + Self::Corrupted => "corrupted", + Self::NotFound => "not_found", + Self::Unknown => "unknown", + } + } +} + +impl std::fmt::Display for WorkingDirectoryStatusKind { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct WorkingDirectoryCleanupTarget { + pub kind: String, + pub working_directory_id: String, + pub repository_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct WorkingDirectoryOccupancy { + pub runtime_id: String, + pub worker_id: String, + pub display_name: String, + pub linked_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RuntimeWorkingDirectoryCleanupTarget { + pub kind: String, + pub working_directory_id: String, + pub repository_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RuntimeWorkingDirectorySummary { + pub working_directory_id: String, + pub repository_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creation_selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creation_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creation_tree: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_tree: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_at_epoch_seconds: Option, + pub materializer_kind: WorkingDirectoryMaterializerKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleanup_target: Option, + pub status: WorkingDirectoryStatusKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleanliness: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub primary_worker_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub occupied_by: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct WorkingDirectorySummary { + pub working_directory_id: String, + pub repository_key: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creation_selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creation_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creation_tree: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_tree: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_at_epoch_seconds: Option, + pub materializer_kind: WorkingDirectoryMaterializerKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleanup_target: Option, + pub status: WorkingDirectoryStatusKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleanliness: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub primary_worker_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub occupied_by: Option, +} + +impl WorkingDirectorySummary { + pub fn is_workspace_managed(&self) -> bool { + self.cleanup_target.is_some() + } +} +pub use WorkingDirectoryMaterializerKind as MaterializerKind; /// Stable Workspace identity for a Worker hosted by a Runtime. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] @@ -89,15 +211,29 @@ mod tests { } #[test] - fn workspace_workdir_projection_reexports_workspace_api_authority() { - assert_eq!( - std::any::TypeId::of::(), - std::any::TypeId::of::() - ); - assert_eq!( - std::any::TypeId::of::(), - std::any::TypeId::of::() - ); + fn working_directory_summary_preserves_server_projection_wire_shape() { + let summary = WorkingDirectorySummary { + working_directory_id: "wd-1".to_string(), + repository_key: "repo-1".to_string(), + creation_selector: None, + creation_ref: None, + creation_tree: None, + current_selector: None, + current_ref: None, + current_tree: None, + observed_at_epoch_seconds: None, + materializer_kind: WorkingDirectoryMaterializerKind::RuntimeGitClone, + cleanup_target: None, + status: WorkingDirectoryStatusKind::Active, + cleanliness: None, + primary_worker_id: None, + occupied_by: None, + }; + let value = serde_json::to_value(summary).unwrap(); + assert_eq!(value["working_directory_id"], "wd-1"); + assert_eq!(value["materializer_kind"], "runtime_git_clone"); + assert_eq!(value["status"], "active"); + assert!(value.get("cleanup_target").is_none()); } } diff --git a/crates/worker-runtime/Cargo.toml b/crates/worker-runtime/Cargo.toml index dabdf1409..e07dde7ef 100644 --- a/crates/worker-runtime/Cargo.toml +++ b/crates/worker-runtime/Cargo.toml @@ -32,6 +32,7 @@ flow = { path = "../flow" } fs4 = { workspace = true, optional = true } manifest.workspace = true protocol.workspace = true +runtime-api.workspace = true serde = { workspace = true, features = ["derive"] } session-store.workspace = true sha2.workspace = true @@ -49,7 +50,7 @@ uuid = { workspace = true, features = ["v7"] } zeroize.workspace = true tower = { workspace = true, features = ["util"], optional = true } worker.workspace = true -workspace-api = { path = "../workspace-api" } +server-api = { path = "../server-api" } workdir.workspace = true [dev-dependencies] diff --git a/crates/worker-runtime/src/catalog.rs b/crates/worker-runtime/src/catalog.rs index f9cfa9b5d..ff99a2d9a 100644 --- a/crates/worker-runtime/src/catalog.rs +++ b/crates/worker-runtime/src/catalog.rs @@ -74,7 +74,7 @@ impl std::ops::Deref for RepositorySelector { pub struct WorkingDirectoryRepository { pub id: String, pub provider: String, - pub source: workspace_api::RepositorySource, + pub source: server_api::RepositorySource, pub source_revision: u64, pub source_fingerprint: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -131,7 +131,7 @@ pub struct RepositorySshMaterializationAccess { pub credential_candidates: Vec, pub host_trust_id: String, pub host_trust_revision: u64, - pub access: workspace_api::RepositoryAccessMode, + pub access: server_api::RepositoryAccessMode, pub expires_at_epoch_seconds: u64, pub repository_id: String, pub repository_source_fingerprint: String, diff --git a/crates/worker-runtime/src/execution.rs b/crates/worker-runtime/src/execution.rs index 0b0aaae34..54934181a 100644 --- a/crates/worker-runtime/src/execution.rs +++ b/crates/worker-runtime/src/execution.rs @@ -286,7 +286,21 @@ pub enum WorkerExecutionSpawnResult { worker_state: protocol::WorkerStateSnapshot, working_directory: Option, }, + /// A read-only preflight rejected the operation before live work started. Rejected(WorkerExecutionResult), + /// Live work started, failed, and all operation-owned resources were joined/released. + RolledBack(WorkerExecutionResult), + /// The operation crossed the live side-effect boundary and could not prove + /// either commit or complete cleanup. Optional handle/state fields preserve + /// concrete execution evidence for retry reconciliation. + ReconciliationRequired { + result: WorkerExecutionResult, + handle: Option, + worker_state: Option, + working_directory: Option, + }, + /// Legacy spawn failure. Restore implementations must use `RolledBack` or + /// `ReconciliationRequired` after crossing their live side-effect boundary. Errored(WorkerExecutionResult), } @@ -304,6 +318,11 @@ impl WorkerExecutionSpawnResult { } } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WorkerSessionObservationRequest { + pub worker_ref: WorkerRef, +} + pub trait WorkerExecutionBackend: Send + Sync + 'static { fn backend_id(&self) -> &str; @@ -314,8 +333,27 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static { Err("execution backend does not support Workspace Config fetching".to_string()) } + fn worker_session( + &self, + _request: WorkerSessionObservationRequest, + ) -> runtime_api::WorkerSessionAvailability { + runtime_api::WorkerSessionAvailability::Unavailable { + reason: runtime_api::WorkerSessionUnavailableReason::StorageUnavailable, + message: "retained session storage is unavailable".to_string(), + } + } + fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult; + /// Read-only restore validation performed before any live controller, + /// bridge, task, or external registration is created. + fn preflight_restore( + &self, + _request: &WorkerExecutionRestoreRequest, + ) -> Result<(), WorkerExecutionResult> { + Ok(()) + } + fn restore_worker( &self, _request: WorkerExecutionRestoreRequest, @@ -502,6 +540,13 @@ impl WorkerExecutionBackendRef { self.backend.fetch_workspace_config(request) } + pub(crate) fn worker_session( + &self, + request: WorkerSessionObservationRequest, + ) -> runtime_api::WorkerSessionAvailability { + self.backend.worker_session(request) + } + pub(crate) fn spawn_worker( &self, request: WorkerExecutionSpawnRequest, @@ -509,6 +554,13 @@ impl WorkerExecutionBackendRef { self.backend.spawn_worker(request) } + pub(crate) fn preflight_restore( + &self, + request: &WorkerExecutionRestoreRequest, + ) -> Result<(), WorkerExecutionResult> { + self.backend.preflight_restore(request) + } + pub(crate) fn restore_worker( &self, request: WorkerExecutionRestoreRequest, diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 9f631ca3f..404071938 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -6,6 +6,8 @@ //! Runtime process directly; a backend is expected to own any browser-facing //! credentials, registration, and policy. +mod runtime_management_api; + use crate::auth::{RuntimeAuthContext, new_token_id, unix_now_seconds}; use crate::catalog::{ ConfigBundleRef, CreateWorkerRequest, RepositoryRefObservationRequest, WorkerDetail, @@ -25,13 +27,11 @@ use crate::retention::{ #[cfg(feature = "ws-server")] use crate::runtime::RuntimeSubscriptionRecvError; use crate::ssh_host_key_probe::{ - SSH_HOST_KEY_PROBE_OPERATION, SSH_HOST_KEY_PROBE_PATH, SSH_KEYSCAN_TIMEOUT, - SshHostKeyProbeError, SshHostKeyProbeRequest, SshHostKeyProbeResponse, - probe_ssh_host_keys_with_program, + SSH_HOST_KEY_PROBE_OPERATION, SSH_KEYSCAN_TIMEOUT, SshHostKeyProbeError, + SshHostKeyProbeRequest, SshHostKeyProbeResponse, probe_ssh_host_keys_with_program, }; use crate::workspace_issuer::{ - RuntimeVerificationSigner, VerifiedWorkspaceCapability, WORKSPACE_VERIFICATION_ACK_PATH, - WORKSPACE_VERIFICATION_CHALLENGE_PATH, WORKSPACE_VERIFICATION_OPERATION, + RuntimeVerificationSigner, VerifiedWorkspaceCapability, WORKSPACE_VERIFICATION_OPERATION, WorkspaceCapabilityExpectation, WorkspaceCapabilityVerifier, WorkspaceRuntimeVerificationAcknowledgement, WorkspaceRuntimeVerificationAuthority, WorkspaceRuntimeVerificationChallenge, WorkspaceRuntimeVerificationReceipt, @@ -164,6 +164,18 @@ pub async fn serve_runtime_http_with_workspace_auth( Ok(()) } +fn remaining_route(path: &'static str, methods: &[&str]) -> &'static str { + for method in methods { + assert!( + runtime_api::REMAINING_RUNTIME_ROUTES + .iter() + .any(|route| route.method == *method && route.path == path), + "handwritten Runtime route {method} {path} is missing from runtime-api inventory", + ); + } + path +} + /// Build the REST router for an existing Runtime. /// /// Handlers delegate to [`Runtime`] methods and keep Worker authority Runtime-local. @@ -209,101 +221,102 @@ fn runtime_http_router_with_auth_and_ssh_keyscan_program( }; let router = Router::new() - .route("/v1/ping", get(get_runtime_ping)) .route( - WORKSPACE_VERIFICATION_CHALLENGE_PATH, + remaining_route(runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE, &["POST"]), post(post_workspace_verification_challenge), ) .route( - WORKSPACE_VERIFICATION_ACK_PATH, + remaining_route(runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK, &["POST"]), post(post_workspace_verification_acknowledgement), ) - .route("/v1/runtime", get(get_runtime)) .route( - "/v1/config-bundles", + remaining_route(runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLES, &["GET", "POST"]), get(list_config_bundles).post(store_config_bundle), ) .route( - "/v1/config-bundles/{bundle_id}/availability", + remaining_route( + runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLE_AVAILABILITY, + &["GET"], + ), get(check_config_bundle), ) .route( - "/v1/workspace-prompt-projections", + remaining_route( + runtime_api::RUNTIME_ROUTE_WORKSPACE_PROMPT_PROJECTIONS, + &["POST"], + ), post(observe_workspace_prompt_projection), ) .route( - "/v1/working-directories", + remaining_route( + runtime_api::RUNTIME_ROUTE_WORKING_DIRECTORIES, + &["GET", "POST"], + ), get(list_working_directories).post(create_working_directory), ) .route( - "/v1/working-directories/repository-access", + remaining_route( + runtime_api::RUNTIME_ROUTE_WORKING_DIRECTORY_REPOSITORY_ACCESS, + &["POST"], + ), post(authorize_working_directory_repository_access), ) .route( - SSH_HOST_KEY_PROBE_PATH, + remaining_route(runtime_api::RUNTIME_ROUTE_SSH_PROBE, &["POST"]), post(probe_repository_ssh_host_keys), ) - .route("/v1/repository-refs/observe", post(observe_repository_ref)) .route( - "/v1/working-directories/{working_directory_id}/sessions", + remaining_route( + runtime_api::RUNTIME_ROUTE_REPOSITORY_REFS_OBSERVE, + &["POST"], + ), + post(observe_repository_ref), + ) + .route( + remaining_route(runtime_api::RUNTIME_ROUTE_WORKDIR_SESSIONS, &["POST"]), post(open_workdir_session), ) .route( - "/v1/workdir-sessions/{session_id}/operations", + remaining_route( + runtime_api::RUNTIME_ROUTE_WORKDIR_SESSION_OPERATIONS, + &["POST"], + ), post(run_workdir_session_operation), ) .route( - "/v1/workdir-sessions/{session_id}", + remaining_route(runtime_api::RUNTIME_ROUTE_WORKDIR_SESSION, &["DELETE"]), delete(close_workdir_session), ) .route( - "/v1/working-directories/{working_directory_id}", + remaining_route( + runtime_api::RUNTIME_ROUTE_WORKING_DIRECTORY, + &["GET", "DELETE"], + ), get(get_working_directory).delete(cleanup_working_directory), ) - .route("/v1/workers", get(list_workers).post(create_worker)) .route( - "/v1/workers/{worker_id}", - get(get_worker).delete(delete_worker), - ) - .route( - "/v1/workers/{worker_id}/retention/inventory", - get(worker_retention_inventory), - ) - .route( - "/v1/workers/{worker_id}/retention/execute", - post(execute_worker_retention), - ) - .route("/v1/workers/{worker_id}/input", post(send_worker_input)) - .route( - "/v1/workers/{worker_id}/attachments", + remaining_route(runtime_api::RUNTIME_ROUTE_WORKER_ATTACHMENTS, &["POST"]), post(upload_worker_file).layer(DefaultBodyLimit::max(MAX_WORKER_FILE_UPLOAD_BYTES)), ) .route( - "/v1/workers/{worker_id}/attachments/{artifact_id}", + remaining_route(runtime_api::RUNTIME_ROUTE_WORKER_ATTACHMENT, &["DELETE"]), delete(delete_worker_uploaded_file), - ) - .route("/v1/workers/{worker_id}/restore", post(restore_worker)) - .route( - "/v1/workers/{worker_id}/workspace-api", - post(replace_worker_workspace_api), - ) - .route( - "/v1/workers/{worker_id}/completions", - post(worker_completions), - ) - .route("/v1/workers/{worker_id}/stop", post(stop_worker)) - .route("/v1/workers/{worker_id}/cancel", post(cancel_worker)); + ); #[cfg(feature = "ws-server")] let router = router - .route("/v1/protocol/ws", get(runtime_protocol_ws)) .route( - "/v1/workers/{worker_id}/protocol/ws", + remaining_route(runtime_api::RUNTIME_ROUTE_PROTOCOL_WS, &["GET"]), + get(runtime_protocol_ws), + ) + .route( + remaining_route(runtime_api::RUNTIME_ROUTE_WORKER_PROTOCOL_WS, &["GET"]), get(worker_protocol_ws), ); router .with_state(state.clone()) + .merge(runtime_management_api::router(state.clone())) .layer(middleware::from_fn_with_state(state, require_runtime_auth)) } @@ -372,11 +385,13 @@ struct RuntimeHttpConfigBundleAvailabilityQuery { digest: String, } +#[allow(dead_code)] #[derive(Clone, Debug, Default, Deserialize)] struct RuntimeHttpWorkersQuery { status: Option, } +#[allow(dead_code)] #[derive(Clone, Copy, Debug, Deserialize)] #[serde(rename_all = "snake_case")] enum RuntimeHttpWorkerStatusFilter { @@ -670,6 +685,7 @@ async fn post_workspace_verification_acknowledgement( })) } +#[allow(dead_code)] async fn get_runtime_ping( State(state): State, Extension(auth): Extension, @@ -712,6 +728,7 @@ async fn get_runtime_ping( })) } +#[allow(dead_code)] async fn get_runtime( State(state): State, ) -> RestResult { @@ -791,6 +808,7 @@ async fn check_config_bundle( })) } +#[allow(dead_code)] async fn list_workers( State(state): State, auth: Option>, @@ -1131,6 +1149,7 @@ async fn cleanup_working_directory( })) } +#[allow(dead_code)] async fn get_worker( State(state): State, auth: Option>, @@ -1145,6 +1164,7 @@ async fn get_worker( Ok(Json(RuntimeHttpWorkerResponse { worker })) } +#[allow(dead_code)] async fn delete_worker( State(state): State, auth: Option>, @@ -1159,6 +1179,7 @@ async fn delete_worker( Ok(Json(RuntimeHttpWorkerDeleteResponse { worker })) } +#[allow(dead_code)] async fn create_worker( State(state): State, auth: Option>, @@ -1173,6 +1194,7 @@ async fn create_worker( Ok(Json(RuntimeHttpWorkerResponse { worker })) } +#[allow(dead_code)] async fn replace_worker_workspace_api( State(state): State, auth: Option>, @@ -1195,20 +1217,6 @@ async fn replace_worker_workspace_api( Ok(Json(RuntimeHttpWorkerResponse { worker })) } -async fn restore_worker( - State(state): State, - auth: Option>, - Path(worker_id): Path, -) -> RestResult { - let worker_ref = worker_ref_for(&state.runtime, worker_id)?; - let worker = match auth_workspace_scope(&state, auth.as_ref())? { - Some(scope) => state.runtime.restore_worker_scoped(&scope, &worker_ref), - None => state.runtime.restore_worker(&worker_ref), - } - .map_err(RuntimeHttpRestError::runtime)?; - Ok(Json(RuntimeHttpWorkerResponse { worker })) -} - #[cfg(feature = "ws-server")] const RUNTIME_PROTOCOL_OUTBOUND_CAPACITY: usize = 256; @@ -1557,7 +1565,6 @@ fn authorize_runtime_protocol_method( protocol::Method::NotifyTracked { notification_request_id, message, - auto_run, .. } => protocol::Method::NotifyTracked { source: transport_source.cloned().unwrap_or_else(|| { @@ -1567,7 +1574,6 @@ fn authorize_runtime_protocol_method( }), notification_request_id, message, - auto_run, }, other => other, } @@ -1758,6 +1764,7 @@ fn protocol_error_event(message: impl Into) -> protocol::Event { } } +#[allow(dead_code)] async fn worker_retention_inventory( State(state): State, auth: Option>, @@ -1778,6 +1785,7 @@ async fn worker_retention_inventory( .map_err(RuntimeHttpRestError::runtime) } +#[allow(dead_code)] async fn execute_worker_retention( State(state): State, auth: Option>, @@ -1813,6 +1821,7 @@ async fn execute_worker_retention( .map_err(RuntimeHttpRestError::runtime) } +#[allow(dead_code)] async fn send_worker_input( State(state): State, auth: Option>, @@ -1829,6 +1838,7 @@ async fn send_worker_input( Ok(Json(RuntimeHttpWorkerInputResponse { ack })) } +#[allow(dead_code)] async fn worker_completions( State(state): State, auth: Option>, @@ -1965,6 +1975,7 @@ async fn delete_worker_uploaded_file( })) } +#[allow(dead_code)] async fn stop_worker( State(state): State, auth: Option>, @@ -1983,6 +1994,7 @@ async fn stop_worker( Ok(Json(RuntimeHttpWorkerLifecycleResponse { ack })) } +#[allow(dead_code)] async fn cancel_worker( State(state): State, auth: Option>, @@ -2080,8 +2092,9 @@ async fn require_runtime_auth( }; match workspace_auth.verifier.verify(token, &expected) { Ok(verified) => { - let is_verification = path_and_query == WORKSPACE_VERIFICATION_CHALLENGE_PATH - || path_and_query == WORKSPACE_VERIFICATION_ACK_PATH; + let is_verification = path_and_query + == runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE + || path_and_query == runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK; if !is_verification { let record = match workspace_auth .verifications @@ -2130,7 +2143,8 @@ async fn require_runtime_auth( token_id: verified.token_id, expires_at: u64::try_from(verified.expires_at).unwrap_or(0), }); - return next.run(request).await; + let context = request.extensions().get::().cloned(); + return runtime_management_api::scope_auth(context, next.run(request)).await; } Err(error) => { return RuntimeHttpRestError::new( @@ -2146,7 +2160,8 @@ async fn require_runtime_auth( let workspace_bootstrap_request = request.method() == Method::POST && matches!( request.uri().path(), - WORKSPACE_VERIFICATION_CHALLENGE_PATH | WORKSPACE_VERIFICATION_ACK_PATH + runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE + | runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK ); if state.workspace_auth.is_some() && !workspace_bootstrap_request { let local_token_matches = state @@ -2180,7 +2195,8 @@ async fn require_runtime_auth( expires_at: 0, }); } - next.run(request).await + let context = request.extensions().get::().cloned(); + runtime_management_api::scope_auth(context, next.run(request)).await } fn auth_workspace_scope( @@ -2217,7 +2233,8 @@ fn auth_workspace_scope( } fn workspace_runtime_operation(method: &Method, path: &str) -> &'static str { - if (path == WORKSPACE_VERIFICATION_CHALLENGE_PATH || path == WORKSPACE_VERIFICATION_ACK_PATH) + if (path == runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE + || path == runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK) && *method == Method::POST { return WORKSPACE_VERIFICATION_OPERATION; @@ -2244,9 +2261,9 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s if path == "/v1/workers" && *method == Method::POST { return Some("workers:create"); } - if (path == "/v1/working-directories/repository-access" - || path == "/v1/repository-refs/observe" - || path == SSH_HOST_KEY_PROBE_PATH) + if (path == runtime_api::RUNTIME_ROUTE_WORKING_DIRECTORY_REPOSITORY_ACCESS + || path == runtime_api::RUNTIME_ROUTE_REPOSITORY_REFS_OBSERVE + || path == runtime_api::RUNTIME_ROUTE_SSH_PROBE) && *method == Method::POST { return Some(SSH_HOST_KEY_PROBE_OPERATION); @@ -2256,9 +2273,9 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s { return Some("workdirs:operate"); } - if path.starts_with("/v1/config-bundles") - || path.starts_with("/v1/workspace-prompt-projections") - || path.starts_with("/v1/working-directories") + if path.starts_with(runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLES) + || path.starts_with(runtime_api::RUNTIME_ROUTE_WORKSPACE_PROMPT_PROJECTIONS) + || path.starts_with(runtime_api::RUNTIME_ROUTE_WORKING_DIRECTORIES) { return Some("workers:create"); } @@ -2271,7 +2288,7 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s if path.ends_with("/stop") || path.ends_with("/cancel") { return Some("workers:stop"); } - if path == "/v1/protocol/ws" { + if path == runtime_api::RUNTIME_ROUTE_PROTOCOL_WS { return Some("workers:list"); } if path.ends_with("/protocol") || path.ends_with("/protocol/ws") { @@ -2466,6 +2483,11 @@ fn status_for_runtime_error(error: &RuntimeError) -> StatusCode { { StatusCode::NOT_FOUND } + RuntimeError::InvalidRequest(message) + if message.contains("already created with a different fingerprint") => + { + StatusCode::CONFLICT + } RuntimeError::RuntimeStopped | RuntimeError::RuntimeStoreAlreadyOpen { .. } | RuntimeError::WorkerExecutionUnavailable { .. } @@ -2504,6 +2526,11 @@ fn code_for_runtime_error(error: &RuntimeError) -> String { RuntimeError::WorkerExecutionRejected { .. } => "worker_execution_rejected".to_string(), RuntimeError::WorkspaceOwnerMismatch { .. } => "workspace_owner_mismatch".to_string(), RuntimeError::LimitTooLarge { .. } => "limit_too_large".to_string(), + RuntimeError::InvalidRequest(message) + if message.contains("already created with a different fingerprint") => + { + "worker_create_conflict".to_string() + } RuntimeError::InvalidRequest(_) => "invalid_request".to_string(), RuntimeError::WorkingDirectory(diagnostic) => diagnostic.code.clone(), RuntimeError::InvalidInitialInputKind { .. } => "invalid_initial_input_kind".to_string(), @@ -2537,7 +2564,7 @@ pub enum RuntimeHttpServerError { mod tests { use super::*; use crate::auth::RuntimeIdentityMaterial; - use crate::catalog::{ConfigBundleRef, ProfileSelector, WorkerStatus, WorkspaceApiRef}; + use crate::catalog::{ConfigBundleRef, ProfileSelector, WorkspaceApiRef}; use crate::config_bundle::{ ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor, }; @@ -2547,6 +2574,7 @@ mod tests { WorkerExecutionSpawnResult, }; use crate::management::RuntimeOptions; + use crate::retention::{DiagnosticsDisposition, SessionDisposition}; use crate::workspace_issuer::{ InMemoryWorkspaceClaimReplayProtection, InMemoryWorkspaceRuntimeVerificationAuthority, WorkspaceCapabilityClaims, WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord, @@ -2556,16 +2584,111 @@ mod tests { use axum::body::to_bytes; use axum::http::Method; use manifest::{Scope, SharedScope}; + use runtime_api::WorkerRestoreState; use sha2::Digest as _; use tower::ServiceExt; + + #[derive(Clone)] + struct TestRuntimeAuthorizer(String); + + impl runtime_api::client_support::RequestAuthorizer for TestRuntimeAuthorizer { + fn authorize( + &self, + _request: runtime_api::client_support::AuthorizerRequest<'_>, + ) -> Result< + runtime_api::client_support::framework::header::HeaderMap, + runtime_api::client_support::AuthorizationError, + > { + let mut headers = runtime_api::client_support::framework::header::HeaderMap::new(); + headers.insert( + runtime_api::client_support::framework::header::AUTHORIZATION, + format!("Bearer {}", self.0) + .parse() + .map_err(|_| runtime_api::client_support::AuthorizationError::new())?, + ); + Ok(headers) + } + } + use workdir::{ GrepOutputMode, GrepRequest, LocalWorkdirSession, StatRequest, Workdir, WorkdirPath, WorkdirSessionCapabilities, }; + #[test] + fn generated_runtime_routes_are_classified_by_permission_authority() { + for operation in ::OPERATIONS { + let method = match operation.method { + runtime_api::HttpMethod::Get => Method::GET, + runtime_api::HttpMethod::Post => Method::POST, + runtime_api::HttpMethod::Put => Method::PUT, + runtime_api::HttpMethod::Patch => Method::PATCH, + runtime_api::HttpMethod::Delete => Method::DELETE, + runtime_api::HttpMethod::Head | runtime_api::HttpMethod::Options => { + panic!("unexpected management contract method") + } + }; + let path = operation.path.replace("{worker_id}", "worker-1"); + let permission = required_runtime_permission(&method, &path); + if operation.operation_id == "runtime_summary" { + assert_eq!( + permission, None, + "{:?} {}", + operation.method, operation.path + ); + } else { + assert!( + permission.is_some(), + "unclassified generated route: {:?} {}", + operation.method, + operation.path + ); + } + } + } + + #[test] + fn remaining_route_inventory_covers_every_handwritten_router_path() { + let inventoried = runtime_api::REMAINING_RUNTIME_ROUTES + .iter() + .map(|route| route.path) + .collect::>(); + for path in [ + runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE, + runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK, + runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLES, + runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLE_AVAILABILITY, + runtime_api::RUNTIME_ROUTE_WORKSPACE_PROMPT_PROJECTIONS, + runtime_api::RUNTIME_ROUTE_WORKING_DIRECTORIES, + runtime_api::RUNTIME_ROUTE_WORKING_DIRECTORY_REPOSITORY_ACCESS, + runtime_api::RUNTIME_ROUTE_SSH_PROBE, + runtime_api::RUNTIME_ROUTE_REPOSITORY_REFS_OBSERVE, + runtime_api::RUNTIME_ROUTE_WORKDIR_SESSIONS, + runtime_api::RUNTIME_ROUTE_WORKDIR_SESSION_OPERATIONS, + runtime_api::RUNTIME_ROUTE_WORKDIR_SESSION, + runtime_api::RUNTIME_ROUTE_WORKING_DIRECTORY, + runtime_api::RUNTIME_ROUTE_WORKER_ATTACHMENTS, + runtime_api::RUNTIME_ROUTE_WORKER_ATTACHMENT, + runtime_api::RUNTIME_ROUTE_PROTOCOL_WS, + runtime_api::RUNTIME_ROUTE_WORKER_PROTOCOL_WS, + ] { + assert!( + inventoried.contains(path), + "missing handwritten route inventory for {path}" + ); + } + } + #[tokio::test] async fn workspace_signed_verification_requires_exact_request_and_acknowledges_response() { - let runtime = Runtime::new_memory(); + let runtime = + Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend)) + .unwrap(); + runtime + .store_config_bundle(test_bundle(ProfileSelector::Builtin( + "builtin:coder".to_string(), + ))) + .unwrap(); let workspace_identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap(); let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap(); let workspace_public_key = @@ -2598,7 +2721,7 @@ mod tests { ) .unwrap(); let app = runtime_http_router_with_workspace_auth( - runtime, + runtime.clone(), None, WorkspaceRuntimeHttpAuth { verifier, @@ -2631,7 +2754,7 @@ mod tests { worker_id: None, operation: WORKSPACE_VERIFICATION_OPERATION.to_string(), method: "POST".to_string(), - path_and_query: WORKSPACE_VERIFICATION_CHALLENGE_PATH.to_string(), + path_and_query: runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE.to_string(), body_digest: workspace_request_body_digest(&body), iat: unix_now_i64(), exp: challenge.expires_at, @@ -2645,7 +2768,7 @@ mod tests { .oneshot( Request::builder() .method(Method::POST) - .uri(WORKSPACE_VERIFICATION_CHALLENGE_PATH) + .uri(runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE) .header(header::AUTHORIZATION, format!("Bearer {token}")) .header(header::CONTENT_TYPE, "application/json") .body(Body::from(body)) @@ -2691,7 +2814,7 @@ mod tests { }; let acknowledgement_body = serde_json::to_vec(&acknowledgement).unwrap(); let acknowledgement_claims = WorkspaceCapabilityClaims { - path_and_query: WORKSPACE_VERIFICATION_ACK_PATH.to_string(), + path_and_query: runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK.to_string(), body_digest: workspace_request_body_digest(&acknowledgement_body), jti: "ack-token".to_string(), ..claims @@ -2706,7 +2829,7 @@ mod tests { .oneshot( Request::builder() .method(Method::POST) - .uri(WORKSPACE_VERIFICATION_ACK_PATH) + .uri(runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK) .header( header::AUTHORIZATION, format!("Bearer {acknowledgement_token}"), @@ -2724,7 +2847,7 @@ mod tests { for authorization in [None, Some("Bearer malformed")] { let mut request = Request::builder() .method(Method::POST) - .uri("/v1/config-bundles"); + .uri(runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLES); if let Some(authorization) = authorization { request = request.header(header::AUTHORIZATION, authorization); } @@ -2750,6 +2873,7 @@ mod tests { ) .unwrap(); let response = app + .clone() .oneshot( Request::builder() .method(Method::GET) @@ -2762,6 +2886,64 @@ mod tests { .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); + + let mut retention_create = task_request("retention-scope"); + retention_create.workspace_api = Some(WorkspaceApiRef { + workspace_id: "workspace-a".to_string(), + base_url: "https://backend.test".to_string(), + }); + let retention_worker_id = retention_create.worker_id.clone(); + runtime.create_worker(retention_create).unwrap(); + let retention_path = format!("/v1/workers/{retention_worker_id}/retention/execute"); + let retention_request = WorkerRetentionExecutionRequest { + operation_id: "retention-op".to_string(), + input_fingerprint: "fingerprint".to_string(), + archive_id: None, + workspace_id: "workspace-b".to_string(), + source_runtime_id: "runtime-a".to_string(), + worker_id: retention_worker_id, + expected_worker_revision: "revision".to_string(), + source_created_at: "2025-01-01T00:00:00Z".to_string(), + removed_at: "2025-01-02T00:00:00Z".to_string(), + effective_profile: None, + retention_class: None, + policy_id: "policy".to_string(), + policy_revision: 1, + session_disposition: SessionDisposition::Purge, + diagnostics_disposition: DiagnosticsDisposition::Retain, + }; + let retention_body = serde_json::to_vec(&retention_request).unwrap(); + let retention_claims = WorkspaceCapabilityClaims { + operation: required_runtime_permission(&Method::POST, &retention_path) + .unwrap() + .to_string(), + method: "POST".to_string(), + path_and_query: retention_path.clone(), + worker_id: Some(retention_worker_id.to_string()), + body_digest: workspace_request_body_digest(&retention_body), + jti: "retention-scope-token".to_string(), + ..ping_claims + }; + let retention_token = issue_workspace_capability_token( + &workspace_identity.signing_key().unwrap(), + &retention_claims, + ) + .unwrap(); + let response = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri(retention_path) + .header(header::AUTHORIZATION, format!("Bearer {retention_token}")) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(retention_body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let error: RuntimeHttpErrorResponse = read_json(response).await; + assert_eq!(error.error.code, "worker_not_found"); } #[test] @@ -2804,7 +2986,6 @@ mod tests { let wire = serde_json::to_string(&protocol::Method::NotifyTracked { notification_request_id: "notification-1".into(), message: "hello".into(), - auto_run: true, source: protocol::AuthenticatedInputSource::Account { account_id: "forged".into(), }, @@ -2919,15 +3100,21 @@ mod tests { #[test] fn workdir_routes_require_dedicated_operation_permission() { assert_eq!( - required_runtime_permission(&Method::POST, "/v1/working-directories/repository-access",), + required_runtime_permission( + &Method::POST, + runtime_api::RUNTIME_ROUTE_WORKING_DIRECTORY_REPOSITORY_ACCESS, + ), Some("workdirs:operate") ); assert_eq!( - required_runtime_permission(&Method::POST, "/v1/repository-refs/observe"), + required_runtime_permission( + &Method::POST, + runtime_api::RUNTIME_ROUTE_REPOSITORY_REFS_OBSERVE + ), Some("workdirs:operate") ); assert_eq!( - required_runtime_permission(&Method::POST, SSH_HOST_KEY_PROBE_PATH), + required_runtime_permission(&Method::POST, runtime_api::RUNTIME_ROUTE_SSH_PROBE), Some(SSH_HOST_KEY_PROBE_OPERATION) ); assert_eq!( @@ -3242,6 +3429,132 @@ mod tests { let token = "local-token"; let app = runtime_http_router(runtime.clone(), token.to_string()); + let malformed_response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/v1/workers") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::from("{")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(malformed_response.status(), StatusCode::BAD_REQUEST); + let malformed: RuntimeHttpErrorResponse = read_json(malformed_response).await; + assert_eq!(malformed.error.code, "invalid_json"); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server_app = app.clone(); + let server = tokio::spawn(async move { + axum::serve(listener, server_app).await.unwrap(); + }); + let client = runtime_api::RuntimeApiClient::builder(&format!("http://{address}")) + .unwrap() + .authorizer(TestRuntimeAuthorizer(token.to_string())) + .build() + .unwrap(); + let generated_request: runtime_api::CreateWorkerRequest = serde_json::from_value( + serde_json::to_value(task_request("generated-roundtrip")).unwrap(), + ) + .unwrap(); + let generated_worker_id = generated_request.worker_id.to_string(); + let mut conflicting_request = generated_request.clone(); + conflicting_request.create_fingerprint = "different-fingerprint".to_string(); + let generated = client.create_worker(generated_request).await.unwrap(); + assert_eq!(generated.worker.worker_id.to_string(), generated_worker_id); + let generated_input: runtime_api::WorkerInput = serde_json::from_value( + serde_json::to_value(WorkerInput::user("generated client input")).unwrap(), + ) + .unwrap(); + client + .send_worker_input(generated_worker_id.clone(), generated_input) + .await + .unwrap(); + client + .stop_worker( + generated_worker_id.clone(), + runtime_api::WorkerLifecycleRequest::default(), + ) + .await + .unwrap(); + client + .restore_worker( + generated_worker_id.clone(), + runtime_api::EmptyObjectRequest::default(), + ) + .await + .unwrap(); + let conflict = client.create_worker(conflicting_request).await.unwrap_err(); + assert!( + matches!( + &conflict, + runtime_api::client_support::ClientError::Public { status, .. } + if *status == StatusCode::CONFLICT + ), + "{conflict:?}" + ); + client + .stop_worker( + generated_worker_id.clone(), + runtime_api::WorkerLifecycleRequest::default(), + ) + .await + .unwrap(); + client + .delete_worker(generated_worker_id.clone()) + .await + .unwrap(); + let missing = client + .get_worker(generated_worker_id.clone()) + .await + .unwrap_err(); + assert!(matches!( + missing, + runtime_api::client_support::ClientError::Public { status, .. } + if status == StatusCode::NOT_FOUND + )); + let unauthorized = runtime_api::RuntimeApiClient::builder(&format!("http://{address}")) + .unwrap() + .build() + .unwrap() + .list_workers(runtime_api::WorkerListQuery::default()) + .await + .unwrap_err(); + assert!(matches!( + unauthorized, + runtime_api::client_support::ClientError::Public { status, .. } + if status == StatusCode::UNAUTHORIZED + )); + let forbidden_response = app + .clone() + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/v1/ping") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(forbidden_response.status(), StatusCode::FORBIDDEN); + let bounded_client = runtime_api::RuntimeApiClient::builder(&format!("http://{address}")) + .unwrap() + .authorizer(TestRuntimeAuthorizer(token.to_string())) + .response_body_limit(1) + .build() + .unwrap(); + assert!(matches!( + bounded_client.runtime_summary().await.unwrap_err(), + runtime_api::client_support::ClientError::Failure( + runtime_api::client_support::ClientFailure::ResponseTooLarge { .. } + ) + )); + server.abort(); + let response = authed_json_request( app.clone(), Method::POST, @@ -3322,8 +3635,12 @@ mod tests { ) .await; assert_eq!(response.status(), StatusCode::OK); - let restored: RuntimeHttpWorkerResponse = read_json(response).await; - assert_eq!(restored.worker.status, WorkerStatus::Idle); + let restored: runtime_api::WorkerRestoreResponse = read_json(response).await; + assert_eq!(restored.state, WorkerRestoreState::Accepted); + assert_eq!( + restored.worker.unwrap().status, + runtime_api::WorkerStatus::Idle + ); let response = authed_empty_request( app.clone(), @@ -3383,11 +3700,11 @@ mod tests { #[test] fn ssh_probe_uses_workdir_operation_capability() { assert_eq!( - required_runtime_permission(&Method::POST, SSH_HOST_KEY_PROBE_PATH), + required_runtime_permission(&Method::POST, runtime_api::RUNTIME_ROUTE_SSH_PROBE), Some(SSH_HOST_KEY_PROBE_OPERATION) ); assert_eq!( - workspace_runtime_operation(&Method::POST, SSH_HOST_KEY_PROBE_PATH), + workspace_runtime_operation(&Method::POST, runtime_api::RUNTIME_ROUTE_SSH_PROBE), SSH_HOST_KEY_PROBE_OPERATION ); } @@ -3404,7 +3721,7 @@ mod tests { let response = authed_json_request( app, Method::POST, - SSH_HOST_KEY_PROBE_PATH, + runtime_api::RUNTIME_ROUTE_SSH_PROBE, token, &SshHostKeyProbeRequest { hostname: "-oProxyCommand=malicious".to_string(), @@ -3469,8 +3786,14 @@ mod tests { .unwrap(); assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED); - let response = - authed_json_request(app, Method::POST, SSH_HOST_KEY_PROBE_PATH, token, &body).await; + let response = authed_json_request( + app, + Method::POST, + runtime_api::RUNTIME_ROUTE_SSH_PROBE, + token, + &body, + ) + .await; assert_eq!(response.status(), StatusCode::OK); assert_eq!( std::fs::read_to_string(recorded_arguments).unwrap(), diff --git a/crates/worker-runtime/src/http_server/runtime_management_api.rs b/crates/worker-runtime/src/http_server/runtime_management_api.rs new file mode 100644 index 000000000..8d29fe188 --- /dev/null +++ b/crates/worker-runtime/src/http_server/runtime_management_api.rs @@ -0,0 +1,518 @@ +use super::*; + +use std::future::Future; + +const MANAGEMENT_BODY_LIMIT: usize = 8 * 1024 * 1024; + +// Axum request extensions remain the authorization authority. The generated adapter deliberately +// has no authentication model, so the verified extension is scoped across its service call. +tokio::task_local! { + static AUTH_CONTEXT: Option; +} + +pub(super) async fn scope_auth(context: Option, future: F) -> F::Output +where + F: Future, +{ + AUTH_CONTEXT.scope(context, future).await +} + +fn auth_extension() -> Option> { + AUTH_CONTEXT + .try_with(Clone::clone) + .ok() + .flatten() + .map(Extension) +} + +fn required_auth() -> Result, runtime_api::RuntimeApiError> { + auth_extension().ok_or_else(|| { + runtime_api::RuntimeApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "workspace_scope_required", + "Runtime operation requires an authorization context", + ) + }) +} + +#[derive(Clone)] +struct RuntimeManagementApi { + state: RuntimeHttpState, +} + +pub(super) fn router(state: RuntimeHttpState) -> Router { + runtime_api::RuntimeApiAxum::router(RuntimeManagementApi { state }) + .layer(axum::extract::DefaultBodyLimit::max(MANAGEMENT_BODY_LIMIT)) + .layer(middleware::from_fn(runtime_api_wire_compatibility)) +} + +async fn runtime_api_wire_compatibility( + mut request: axum::extract::Request, + next: Next, +) -> Response { + let is_json_request = !matches!(*request.method(), Method::GET | Method::HEAD); + let is_query_request = request.method() == Method::GET; + if request.uri().path() == "/v1/ping" && !request.headers().contains_key("x-yoi-workspace-id") { + return RuntimeHttpRestError::new( + StatusCode::FORBIDDEN, + "runtime_ping_workspace_scope_required", + "Runtime ping requires an authenticated Workspace scope", + ) + .into_response(); + } + if is_json_request { + if !request.headers().contains_key(header::CONTENT_TYPE) { + request.headers_mut().insert( + header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/json"), + ); + } + let (parts, body) = request.into_parts(); + let body = match axum::body::to_bytes(body, MANAGEMENT_BODY_LIMIT).await { + Ok(body) if body.is_empty() => Bytes::from_static(b"{}"), + Ok(body) => body, + Err(_) => { + return RuntimeHttpRestError::new( + StatusCode::PAYLOAD_TOO_LARGE, + "request_body_too_large", + "request body exceeds Runtime management limit", + ) + .into_response(); + } + }; + request = axum::extract::Request::from_parts(parts, Body::from(body)); + } + let response = next.run(request).await; + let is_json_error = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("application/json")); + if matches!( + response.status(), + StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY + ) && !is_json_error + { + return RuntimeHttpRestError::new( + StatusCode::BAD_REQUEST, + if is_query_request { + "invalid_query" + } else { + "invalid_json" + }, + if is_query_request { + "request query parameters are invalid" + } else { + "request body must be valid JSON" + }, + ) + .into_response(); + } + response +} + +fn request(value: T) -> Result +where + T: Serialize, + U: serde::de::DeserializeOwned, +{ + serde_json::to_value(value) + .and_then(serde_json::from_value) + .map_err(|error| { + runtime_api::RuntimeApiError::new( + StatusCode::BAD_REQUEST.as_u16(), + "invalid_request", + error.to_string(), + ) + }) +} + +fn response(value: T) -> Result +where + T: Serialize, + U: serde::de::DeserializeOwned, +{ + serde_json::to_value(value) + .and_then(serde_json::from_value) + .map_err(|error| { + runtime_api::RuntimeApiError::new( + StatusCode::INTERNAL_SERVER_ERROR.as_u16(), + "runtime_response_conversion_failed", + error.to_string(), + ) + }) +} + +fn api_error(error: RuntimeHttpRestError) -> runtime_api::RuntimeApiError { + runtime_api::RuntimeApiError::new(error.status.as_u16(), error.code, error.message) +} + +impl runtime_api::RuntimeApi for RuntimeManagementApi { + async fn ping( + &self, + workspace_id: String, + ) -> Result { + let Extension(auth) = required_auth()?; + if workspace_id != auth.workspace_id { + return Err(runtime_api::RuntimeApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "runtime_ping_workspace_scope_mismatch", + "Runtime ping Workspace scope does not match the authenticated capability", + )); + } + let runtime_id = self + .state + .workspace_auth + .as_ref() + .map(|auth| auth.signer.runtime_id().trim()) + .filter(|runtime_id| !runtime_id.is_empty()) + .ok_or_else(|| { + runtime_api::RuntimeApiError::new( + StatusCode::SERVICE_UNAVAILABLE.as_u16(), + "runtime_ping_identity_unavailable", + "Runtime ping identity is not configured", + ) + })?; + Ok(runtime_api::RuntimePingResponse { + runtime_id: runtime_id.to_string(), + protocol_version: RUNTIME_HTTP_PROTOCOL_VERSION, + }) + } + + async fn runtime_summary( + &self, + ) -> Result { + let runtime = self + .state + .runtime + .summary() + .map_err(RuntimeHttpRestError::runtime) + .map_err(api_error)?; + let runtime = response(runtime)?; + Ok(runtime_api::RuntimeSummaryResponse { runtime }) + } + + async fn list_workers( + &self, + query: runtime_api::WorkerListQuery, + ) -> Result { + let scope = + auth_workspace_scope(&self.state, auth_extension().as_ref()).map_err(api_error)?; + let workers = match (query.status, scope.as_ref()) { + (Some(runtime_api::WorkerStatusFilter::Stopped), Some(scope)) => { + self.state.runtime.list_stopped_workers_scoped(scope) + } + (Some(runtime_api::WorkerStatusFilter::Stopped), None) => { + self.state.runtime.list_stopped_workers() + } + (None, Some(scope)) => self.state.runtime.list_workers_scoped(scope), + (None, None) => self.state.runtime.list_workers(), + } + .map_err(RuntimeHttpRestError::runtime) + .map_err(api_error)?; + let workers = response(workers)?; + Ok(runtime_api::WorkersResponse { workers }) + } + + async fn get_worker( + &self, + worker_id: String, + ) -> Result { + let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?; + let worker = match auth_workspace_scope(&self.state, auth_extension().as_ref()) + .map_err(api_error)? + { + Some(scope) => self.state.runtime.worker_detail_scoped(&scope, &worker_ref), + None => self.state.runtime.worker_detail(&worker_ref), + } + .map_err(RuntimeHttpRestError::runtime) + .map_err(api_error)?; + let worker = response(worker)?; + Ok(runtime_api::WorkerResponse { worker }) + } + + async fn worker_session( + &self, + worker_id: String, + request: runtime_api::WorkerSessionRequest, + ) -> Result { + let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?; + let scope = auth_workspace_scope(&self.state, auth_extension().as_ref()) + .map_err(api_error)? + .ok_or_else(|| { + runtime_api::RuntimeApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "runtime_worker_session_scope_required", + "Worker Session observation requires a Workspace-scoped capability", + ) + })?; + if scope.workspace_id != request.workspace_id { + return Err(runtime_api::RuntimeApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "runtime_worker_session_workspace_scope_mismatch", + "Worker Session Workspace scope does not match the authenticated capability", + )); + } + self.state + .runtime + .worker_session_scoped(&scope, &worker_ref) + .map_err(RuntimeHttpRestError::runtime) + .map_err(api_error) + } + + async fn create_worker( + &self, + value: runtime_api::CreateWorkerRequest, + ) -> Result { + let request: CreateWorkerRequest = request(value)?; + let worker = match auth_workspace_scope(&self.state, auth_extension().as_ref()) + .map_err(api_error)? + { + Some(scope) => self.state.runtime.create_worker_scoped(&scope, request), + None => self.state.runtime.create_worker(request), + } + .map_err(RuntimeHttpRestError::runtime) + .map_err(api_error)?; + let worker = response(worker)?; + Ok(runtime_api::WorkerResponse { worker }) + } + + async fn delete_worker( + &self, + worker_id: String, + ) -> Result { + let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?; + let worker = match auth_workspace_scope(&self.state, auth_extension().as_ref()) + .map_err(api_error)? + { + Some(scope) => self.state.runtime.delete_worker_scoped(&scope, &worker_ref), + None => self.state.runtime.delete_worker(&worker_ref), + } + .map_err(RuntimeHttpRestError::runtime) + .map_err(api_error)?; + let worker = response(worker)?; + Ok(runtime_api::WorkerDeleteResponse { worker }) + } + + async fn send_worker_input( + &self, + worker_id: String, + input: runtime_api::WorkerInput, + ) -> Result { + let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?; + let input: WorkerInput = request(input)?; + let ack = match auth_workspace_scope(&self.state, auth_extension().as_ref()) + .map_err(api_error)? + { + Some(scope) => self + .state + .runtime + .send_input_scoped(&scope, &worker_ref, input), + None => self.state.runtime.send_input(&worker_ref, input), + } + .map_err(RuntimeHttpRestError::runtime) + .map_err(api_error)?; + let ack = response(ack)?; + Ok(runtime_api::WorkerInputResponse { ack }) + } + + async fn stop_worker( + &self, + worker_id: String, + value: runtime_api::WorkerLifecycleRequest, + ) -> Result { + let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?; + let ack = match auth_workspace_scope(&self.state, auth_extension().as_ref()) + .map_err(api_error)? + { + Some(scope) => self + .state + .runtime + .stop_worker_scoped(&scope, &worker_ref, value.reason), + None => self.state.runtime.stop_worker(&worker_ref, value.reason), + } + .map_err(RuntimeHttpRestError::runtime) + .map_err(api_error)?; + let ack = response(ack)?; + Ok(runtime_api::WorkerLifecycleResponse { ack }) + } + + async fn cancel_worker( + &self, + worker_id: String, + value: runtime_api::WorkerLifecycleRequest, + ) -> Result { + let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?; + let ack = match auth_workspace_scope(&self.state, auth_extension().as_ref()) + .map_err(api_error)? + { + Some(scope) => { + self.state + .runtime + .cancel_worker_scoped(&scope, &worker_ref, value.reason) + } + None => self.state.runtime.cancel_worker(&worker_ref, value.reason), + } + .map_err(RuntimeHttpRestError::runtime) + .map_err(api_error)?; + let ack = response(ack)?; + Ok(runtime_api::WorkerLifecycleResponse { ack }) + } + + async fn restore_worker( + &self, + worker_id: String, + _request: runtime_api::EmptyObjectRequest, + ) -> Result { + let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?; + let result = match auth_workspace_scope(&self.state, auth_extension().as_ref()) + .map_err(api_error)? + { + Some(scope) => self + .state + .runtime + .restore_worker_operation_scoped(&scope, &worker_ref), + None => self.state.runtime.restore_worker_operation(&worker_ref), + } + .map_err(RuntimeHttpRestError::runtime) + .map_err(api_error)?; + Ok(runtime_api::WorkerRestoreResponse { + state: match result.state { + server_api::WorkerRestoreState::Accepted => { + runtime_api::WorkerRestoreState::Accepted + } + server_api::WorkerRestoreState::Rejected => { + runtime_api::WorkerRestoreState::Rejected + } + server_api::WorkerRestoreState::RolledBack => { + runtime_api::WorkerRestoreState::RolledBack + } + server_api::WorkerRestoreState::ReconciliationRequired => { + runtime_api::WorkerRestoreState::ReconciliationRequired + } + }, + worker: result.worker.map(response).transpose()?, + reason_code: result.reason_code, + message: result.message, + }) + } + + async fn replace_worker_workspace_api( + &self, + worker_id: String, + value: runtime_api::WorkerWorkspaceApiRequest, + ) -> Result { + let workspace_api: WorkspaceApiRef = request(value.workspace_api)?; + let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?; + let worker = match auth_workspace_scope(&self.state, auth_extension().as_ref()) + .map_err(api_error)? + { + Some(scope) => self.state.runtime.replace_worker_workspace_api_scoped( + &scope, + &worker_ref, + workspace_api, + ), + None => self + .state + .runtime + .replace_worker_workspace_api(&worker_ref, workspace_api), + } + .map_err(RuntimeHttpRestError::runtime) + .map_err(api_error)?; + let worker = response(worker)?; + Ok(runtime_api::WorkerResponse { worker }) + } + + async fn complete_worker_arguments( + &self, + worker_id: String, + value: runtime_api::CompletionRequest, + ) -> Result { + let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?; + let kind = value.kind; + let prefix = value.prefix; + let entries = match auth_workspace_scope(&self.state, auth_extension().as_ref()) + .map_err(api_error)? + { + Some(scope) => { + self.state + .runtime + .worker_completions_scoped(&scope, &worker_ref, kind, &prefix) + } + None => self + .state + .runtime + .worker_completions(&worker_ref, kind, &prefix), + } + .map_err(RuntimeHttpRestError::runtime) + .map_err(api_error)?; + let entries = response(entries)?; + Ok(runtime_api::CompletionResponse { + kind, + prefix, + entries, + }) + } + + async fn retention_inventory( + &self, + worker_id: String, + ) -> Result { + let scope = auth_workspace_scope(&self.state, auth_extension().as_ref()) + .map_err(api_error)? + .ok_or_else(|| { + runtime_api::RuntimeApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "workspace_scope_required", + "Worker retention inventory requires workspace-scoped authorization", + ) + })?; + let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?; + let inventory = self + .state + .runtime + .worker_retention_inventory(&scope.workspace_id, &worker_ref) + .map_err(RuntimeHttpRestError::runtime) + .map_err(api_error)?; + response(inventory) + } + + async fn execute_retention( + &self, + worker_id: String, + value: runtime_api::WorkerRetentionExecutionRequest, + ) -> Result { + let scope = auth_workspace_scope(&self.state, auth_extension().as_ref()) + .map_err(api_error)? + .ok_or_else(|| { + runtime_api::RuntimeApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "workspace_scope_required", + "Worker retention execution requires workspace-scoped authorization", + ) + })?; + let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?; + let request: WorkerRetentionExecutionRequest = request(value)?; + if request.workspace_id != scope.workspace_id { + return Err(runtime_api::RuntimeApiError::new( + StatusCode::NOT_FOUND.as_u16(), + "worker_not_found", + "worker does not belong to the authenticated Workspace", + )); + } + if request.worker_id != worker_ref.worker_id { + return Err(runtime_api::RuntimeApiError::new( + StatusCode::BAD_REQUEST.as_u16(), + "worker_id_mismatch", + "Worker retention request worker_id does not match route worker_id", + )); + } + let result = self + .state + .runtime + .execute_worker_retention(&request) + .map_err(RuntimeHttpRestError::runtime) + .map_err(api_error)?; + response(result) + } +} diff --git a/crates/worker-runtime/src/main.rs b/crates/worker-runtime/src/main.rs index adec4f1bb..835efde8b 100644 --- a/crates/worker-runtime/src/main.rs +++ b/crates/worker-runtime/src/main.rs @@ -1355,8 +1355,8 @@ mod tests { #[test] fn workspace_issuer_trust_cli_persists_across_reload_and_writes_private_mode() { + use server_api::WorkspacePublicIdentityBundle; use sha2::{Digest as _, Sha256}; - use workspace_api::WorkspacePublicIdentityBundle; let temp = tempfile::tempdir().unwrap(); let identity = RuntimeIdentityMaterial::generate("WK-1").unwrap(); diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 423f3927c..6b440706a 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -15,7 +15,8 @@ use crate::execution::WorkerExecutionRestoreRequest; use crate::execution::{ WorkerExecutionBackend, WorkerExecutionBackendRef, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult, WorkerExecutionSpawnRequest, - WorkerExecutionSpawnResult, WorkspaceConfigFetchRequest, WorkspaceConfigFetchResult, + WorkerExecutionSpawnResult, WorkerSessionObservationRequest, WorkspaceConfigFetchRequest, + WorkspaceConfigFetchResult, }; #[cfg(feature = "fs-store")] use crate::fs_store::{ @@ -45,6 +46,7 @@ use protocol::subscription::{ SubscriptionWorkerState, }; use protocol::{Event, Method}; +use server_api::WorkerRestoreState; use std::collections::BTreeMap; #[cfg(feature = "ws-server")] use std::collections::VecDeque; @@ -83,6 +85,47 @@ enum WorkerRestoreMode { Automatic, } +/// Runtime-internal restore result consumed by both embedded and HTTP +/// providers. Diagnostics are stable and path-free; detailed backend errors +/// remain in Runtime logs rather than crossing the API boundary. +#[derive(Clone, Debug)] +pub struct RuntimeWorkerRestoreResult { + pub state: WorkerRestoreState, + pub worker: Option, + pub reason_code: Option, + pub message: Option, +} + +impl RuntimeWorkerRestoreResult { + fn accepted(worker: WorkerDetail) -> Self { + Self { + state: WorkerRestoreState::Accepted, + worker: Some(worker), + reason_code: None, + message: None, + } + } + + fn failed(state: WorkerRestoreState, reason_code: &'static str, message: &'static str) -> Self { + Self { + state, + worker: None, + reason_code: Some(reason_code.to_string()), + message: Some(message.to_string()), + } + } + + fn into_legacy_result(self) -> Result { + match (self.state, self.worker) { + (WorkerRestoreState::Accepted, Some(worker)) => Ok(worker), + _ => Err(RuntimeError::InvalidRequest( + self.message + .unwrap_or_else(|| "Worker restore did not commit".to_string()), + )), + } + } +} + #[derive(Clone, Debug)] pub struct RuntimeSubscriptionUpdate { pub subject_revision: u64, @@ -1047,6 +1090,7 @@ impl Runtime { working_directory, } => (handle, worker_state, working_directory), WorkerExecutionSpawnResult::Rejected(result) + | WorkerExecutionSpawnResult::RolledBack(result) | WorkerExecutionSpawnResult::Errored(result) => { self.rollback_failed_create(&worker_ref)?; return Err(RuntimeError::WorkerExecutionRejected { @@ -1057,6 +1101,16 @@ impl Runtime { result, }); } + WorkerExecutionSpawnResult::ReconciliationRequired { result, .. } => { + self.rollback_failed_create(&worker_ref)?; + return Err(RuntimeError::WorkerExecutionRejected { + worker_id: worker_ref.worker_id.clone(), + operation: result.operation, + outcome: result.outcome, + message: result.message_or_default(), + result, + }); + } }; if let Some(mut initial_input) = { @@ -1371,13 +1425,53 @@ impl Runtime { Ok(state.worker(worker_ref)?.detail()) } + /// Observe a Worker's live protocol availability or its retained Session. + /// The same lifecycle lock used by stop/restore fences the decision and read. + pub fn worker_session_scoped( + &self, + scope: &RuntimeWorkspaceScope, + worker_ref: &WorkerRef, + ) -> Result { + let operation_lock = self.worker_operation_lock(worker_ref.worker_id)?; + let _operation_guard = operation_lock + .lock() + .map_err(|_| RuntimeError::StatePoisoned)?; + self.ensure_worker_in_workspace(scope, worker_ref)?; + let backend = { + let state = self.lock()?; + let worker = state.worker(worker_ref)?; + if worker.execution_handle.is_some() && worker.status.is_active() { + return Ok(runtime_api::WorkerSessionAvailability::LiveProtocol); + } + state.execution_backend.clone() + }; + Ok(match backend { + Some(backend) => backend.worker_session(WorkerSessionObservationRequest { + worker_ref: worker_ref.clone(), + }), + None => runtime_api::WorkerSessionAvailability::Unavailable { + reason: runtime_api::WorkerSessionUnavailableReason::StorageUnavailable, + message: "retained session storage is unavailable".to_string(), + }, + }) + } + /// Attach a live execution through a workspace-scoped Runtime authorization context. pub fn restore_worker_scoped( &self, scope: &RuntimeWorkspaceScope, worker_ref: &WorkerRef, ) -> Result { - self.restore_worker_with_scope(worker_ref, Some(scope)) + self.restore_worker_operation_scoped(scope, worker_ref)? + .into_legacy_result() + } + + pub fn restore_worker_operation_scoped( + &self, + scope: &RuntimeWorkspaceScope, + worker_ref: &WorkerRef, + ) -> Result { + self.restore_worker_operation_with_scope(worker_ref, Some(scope)) } /// Attach a live execution to a persisted Worker definition. @@ -1387,14 +1481,22 @@ impl Runtime { /// converges on its already-installed execution rather than spawning another /// controller. pub fn restore_worker(&self, worker_ref: &WorkerRef) -> Result { - self.restore_worker_with_scope(worker_ref, None) + self.restore_worker_operation(worker_ref)? + .into_legacy_result() } - fn restore_worker_with_scope( + pub fn restore_worker_operation( + &self, + worker_ref: &WorkerRef, + ) -> Result { + self.restore_worker_operation_with_scope(worker_ref, None) + } + + fn restore_worker_operation_with_scope( &self, worker_ref: &WorkerRef, scope: Option<&RuntimeWorkspaceScope>, - ) -> Result { + ) -> Result { let operation_lock = self.worker_operation_lock(worker_ref.worker_id)?; let _operation_guard = operation_lock .lock() @@ -1409,58 +1511,54 @@ impl Runtime { &self, worker_ref: &WorkerRef, mode: WorkerRestoreMode, - ) -> Result { + ) -> Result { let (backend, request) = { - let mut state = self.lock()?; + let state = self.lock()?; state.ensure_running()?; - let (worker_request, previous_working_directory) = { - let worker = state.worker(worker_ref)?; - if worker.execution_handle.is_some() { - if worker.status.is_active() { - return Ok(worker.detail()); - } - return Err(RuntimeError::WorkerExecutionUnavailable { - worker_id: worker_ref.worker_id, - message: "previous execution cleanup has not completed".to_string(), - }); + let worker = state.worker(worker_ref)?; + if worker.execution_handle.is_some() { + if worker.status.is_active() { + return Ok(RuntimeWorkerRestoreResult::accepted(worker.detail())); } - match mode { - WorkerRestoreMode::Explicit - if worker.execution_metadata_available - && worker.status != WorkerStatus::Stopped => - { - return Err(RuntimeError::InvalidRequest(format!( - "worker {} is not stopped", - worker_ref.worker_id - ))); - } - WorkerRestoreMode::Automatic - if !worker.status.is_active() - || worker.restore_intent != WorkerRestoreIntent::Automatic => - { - return Ok(worker.detail()); - } - _ => {} - } - let request = worker.request.clone().ok_or_else(|| { - RuntimeError::WorkerExecutionUnavailable { - worker_id: worker.worker_id, - message: "persisted Worker restore request is unavailable".to_string(), - } - })?; - (request, worker.working_directory.clone()) - }; - let backend = state.execution_backend.clone().ok_or_else(|| { - RuntimeError::WorkerExecutionUnavailable { - worker_id: worker_ref.worker_id, - message: "runtime has no execution backend".to_string(), - } - })?; - { - let worker = state.worker_mut(worker_ref)?; - worker.execution_bound = true; + return Ok(RuntimeWorkerRestoreResult::failed( + WorkerRestoreState::ReconciliationRequired, + "worker_restore_cleanup_pending", + "Worker restore cleanup or reconciliation is still pending", + )); } - state.persist_worker(&worker_ref.worker_id)?; + match mode { + WorkerRestoreMode::Explicit + if worker.execution_metadata_available + && worker.status != WorkerStatus::Stopped => + { + return Ok(RuntimeWorkerRestoreResult::failed( + WorkerRestoreState::Rejected, + "worker_restore_not_stopped", + "Worker restore requires a stopped Worker", + )); + } + WorkerRestoreMode::Automatic + if !worker.status.is_active() + || worker.restore_intent != WorkerRestoreIntent::Automatic => + { + return Ok(RuntimeWorkerRestoreResult::accepted(worker.detail())); + } + _ => {} + } + let Some(worker_request) = worker.request.clone() else { + return Ok(RuntimeWorkerRestoreResult::failed( + WorkerRestoreState::Rejected, + "worker_restore_metadata_unavailable", + "Persisted Worker restore metadata is unavailable", + )); + }; + let Some(backend) = state.execution_backend.clone() else { + return Ok(RuntimeWorkerRestoreResult::failed( + WorkerRestoreState::Rejected, + "worker_restore_backend_unavailable", + "Runtime has no Worker execution backend", + )); + }; let workspace_scope = worker_request.workspace_api.as_ref().and_then(|api| { state .workspace_owners @@ -1472,13 +1570,21 @@ impl Runtime { request: worker_request, workspace_scope, context: self.execution_context(worker_ref.clone()), - previous_working_directory, + previous_working_directory: worker.working_directory.clone(), working_directory: None, config_bundle: None, }; (backend, request) }; + if let Err(_result) = backend.preflight_restore(&request) { + return Ok(RuntimeWorkerRestoreResult::failed( + WorkerRestoreState::Rejected, + "worker_restore_preflight_rejected", + "Worker restore preflight rejected the current request", + )); + } + match backend.restore_worker(request) { WorkerExecutionSpawnResult::Connected { handle, @@ -1488,30 +1594,118 @@ impl Runtime { let commit = self.commit_restored_worker_execution( worker_ref, handle.clone(), - worker_state, + worker_state.clone(), WorkerStatus::Idle, - working_directory, + working_directory.clone(), ); - if let Err(error) = commit { - self.cleanup_failed_restore(&backend, worker_ref, &handle)?; - return Err(error); + match commit { + Ok(worker) => Ok(RuntimeWorkerRestoreResult::accepted(worker)), + Err(error) => { + match self.cleanup_failed_restore(&backend, worker_ref, &handle) { + Ok(()) => { + tracing::warn!( + worker_id = %worker_ref.worker_id, + error = %error, + "Worker restore commit failed and was rolled back" + ); + Ok(RuntimeWorkerRestoreResult::failed( + WorkerRestoreState::RolledBack, + "worker_restore_commit_rolled_back", + "Worker restore commit failed and cleanup completed", + )) + } + Err(cleanup_error) => { + tracing::error!( + worker_id = %worker_ref.worker_id, + error = %error, + cleanup_error = %cleanup_error, + "Worker restore requires reconciliation after commit and cleanup failures" + ); + if let Err(retain_error) = self.retain_restore_execution_evidence( + worker_ref, + handle, + worker_state, + working_directory, + ) { + tracing::error!( + worker_id = %worker_ref.worker_id, + error = %retain_error, + "failed to persist restore execution evidence; in-process reconciliation remains required" + ); + } + Ok(RuntimeWorkerRestoreResult::failed( + WorkerRestoreState::ReconciliationRequired, + "worker_restore_reconciliation_required", + "Worker restore result is uncertain; reread or retry the Worker restore", + )) + } + } + } } - self.worker_detail(worker_ref) } - WorkerExecutionSpawnResult::Rejected(result) - | WorkerExecutionSpawnResult::Errored(result) => { + WorkerExecutionSpawnResult::Rejected(_result) => { + Ok(RuntimeWorkerRestoreResult::failed( + WorkerRestoreState::Rejected, + "worker_restore_rejected", + "Worker restore was rejected before live work started", + )) + } + WorkerExecutionSpawnResult::RolledBack(_result) => { #[cfg(feature = "fs-store")] + self.lock()?.record_restore_failure(worker_ref, _result)?; + Ok(RuntimeWorkerRestoreResult::failed( + WorkerRestoreState::RolledBack, + "worker_restore_rolled_back", + "Worker restore failed after live work started; cleanup completed", + )) + } + WorkerExecutionSpawnResult::ReconciliationRequired { + result: _result, + handle, + worker_state, + working_directory, + } => { + if let (Some(handle), Some(worker_state)) = (handle, worker_state) { + if let Err(retain_error) = self.retain_restore_execution_evidence( + worker_ref, + handle, + worker_state, + working_directory, + ) { + tracing::error!( + worker_id = %worker_ref.worker_id, + error = %retain_error, + "failed to persist restore execution evidence; in-process reconciliation remains required" + ); + } + } else if let Err(retain_error) = + self.retain_restore_reconciliation_pending(worker_ref) { - self.lock()? - .record_restore_failure(worker_ref, result.clone())?; + tracing::error!( + worker_id = %worker_ref.worker_id, + error = %retain_error, + "failed to persist pending restore reconciliation marker" + ); } - Err(RuntimeError::WorkerExecutionRejected { - worker_id: worker_ref.worker_id, - operation: result.operation, - outcome: result.outcome, - message: result.message_or_default(), - result, - }) + Ok(RuntimeWorkerRestoreResult::failed( + WorkerRestoreState::ReconciliationRequired, + "worker_restore_reconciliation_required", + "Worker restore result is uncertain; reread or retry the Worker restore", + )) + } + WorkerExecutionSpawnResult::Errored(_result) => { + if let Err(retain_error) = self.retain_restore_reconciliation_pending(worker_ref) { + tracing::error!( + worker_id = %worker_ref.worker_id, + error = %retain_error, + "failed to persist pending restore reconciliation marker" + ); + } + Ok(RuntimeWorkerRestoreResult::failed( + WorkerRestoreState::ReconciliationRequired, + "worker_restore_backend_outcome_unknown", + "Worker restore backend outcome is uncertain; reread or retry the Worker restore", + )) } } } @@ -2469,27 +2663,83 @@ impl Runtime { worker_state: protocol::WorkerStateSnapshot, status: WorkerStatus, working_directory: Option, - ) -> Result<(), RuntimeError> { + ) -> Result { let mut state = self.lock()?; state.ensure_worker_ref(worker_ref)?; - { - let worker = state.worker_mut(worker_ref)?; - if worker.execution_handle.is_some() { - return Err(RuntimeError::InvalidRequest(format!( - "worker {} already has a current execution", - worker_ref.worker_id - ))); - } - worker.execution_handle = Some(handle); - worker.execution_metadata_available = true; - worker.execution_bound = true; - worker.status = status; - let _ = worker.apply_worker_state(&worker_state); - worker.restore_intent = restore_intent_for_status(worker.status); - worker.working_directory = working_directory; + let mut candidate = state.worker(worker_ref)?.clone(); + if candidate.execution_handle.is_some() { + return Err(RuntimeError::InvalidRequest(format!( + "worker {} already has a current execution", + worker_ref.worker_id + ))); } + candidate.execution_handle = Some(handle); + candidate.execution_metadata_available = true; + candidate.execution_bound = true; + candidate.status = status; + let _ = candidate.apply_worker_state(&worker_state); + candidate.restore_intent = restore_intent_for_status(candidate.status); + candidate.working_directory = working_directory; + + // Validate the exact subscription projection before persistence. The + // subsequent publish uses the same pure projection while the Worker + // operation lock keeps this candidate stable. + let _subscription_projection = state.subscription_worker(&candidate)?; + + // Persist the complete candidate before making it observable. A failed + // write therefore leaves both the in-memory catalog and subscriptions + // on the pre-restore record, so cleanup can produce a true rollback. + state.persist_worker_record(&candidate)?; + let detail = candidate.detail(); + state.workers.insert(worker_ref.worker_id, candidate); + state.publish_worker_upsert(worker_ref.worker_id)?; + if let Err(error) = state.persist_runtime_snapshot() { + tracing::warn!( + worker_id = %worker_ref.worker_id, + error = %error, + "Worker restore committed to its canonical record but Runtime snapshot refresh failed" + ); + } + Ok(detail) + } + + fn retain_restore_execution_evidence( + &self, + worker_ref: &WorkerRef, + handle: WorkerExecutionHandle, + worker_state: protocol::WorkerStateSnapshot, + working_directory: Option, + ) -> Result<(), RuntimeError> { + let mut state = self.lock()?; + let mut candidate = state.worker(worker_ref)?.clone(); + candidate.execution_handle = Some(handle); + candidate.execution_metadata_available = true; + candidate.execution_bound = true; + candidate.status = WorkerStatus::Idle; + let _ = candidate.apply_worker_state(&worker_state); + candidate.restore_intent = WorkerRestoreIntent::Automatic; + candidate.working_directory = working_directory; + state.workers.insert(worker_ref.worker_id, candidate); + state.publish_worker_upsert(worker_ref.worker_id)?; + state.persist_worker(&worker_ref.worker_id)?; + Ok(()) + } + + fn retain_restore_reconciliation_pending( + &self, + worker_ref: &WorkerRef, + ) -> Result<(), RuntimeError> { + let mut state = self.lock()?; + let mut candidate = state.worker(worker_ref)?.clone(); + candidate.execution_handle = None; + candidate.execution_metadata_available = false; + candidate.execution_bound = true; + // Active-without-metadata is the durable pending-reconciliation marker: + // it prevents a false Stopped projection and blocks duplicate restore. + candidate.status = WorkerStatus::Idle; + candidate.restore_intent = WorkerRestoreIntent::Explicit; + state.workers.insert(worker_ref.worker_id, candidate); state.publish_worker_upsert(worker_ref.worker_id)?; - state.persist_runtime_snapshot()?; state.persist_worker(&worker_ref.worker_id)?; Ok(()) } @@ -2513,18 +2763,6 @@ impl Runtime { result, }); } - - let mut state = self.lock()?; - let worker = state.worker_mut(worker_ref)?; - worker.execution_handle = None; - worker.execution_bound = false; - worker.status = WorkerStatus::Stopped; - worker.worker_state = None; - worker.restore_intent = WorkerRestoreIntent::Explicit; - worker.internal_workers.clear(); - state.publish_worker_upsert(worker_ref.worker_id)?; - state.persist_runtime_snapshot()?; - state.persist_worker(&worker_ref.worker_id)?; Ok(()) } @@ -2908,6 +3146,14 @@ impl RuntimeState { Ok(()) } + #[cfg(feature = "fs-store")] + fn persist_worker_record(&self, worker: &WorkerRecord) -> Result<(), RuntimeError> { + if let Some(store) = self.fs_store() { + store.write_worker_record(&worker.persisted_record())?; + } + Ok(()) + } + #[cfg(feature = "fs-store")] fn persist_worker(&self, worker_id: &WorkerId) -> Result<(), RuntimeError> { if let Some(store) = self.fs_store() { @@ -2945,6 +3191,11 @@ impl RuntimeState { Ok(()) } + #[cfg(not(feature = "fs-store"))] + fn persist_worker_record(&self, _worker: &WorkerRecord) -> Result<(), RuntimeError> { + Ok(()) + } + #[cfg(not(feature = "fs-store"))] fn persist_worker(&self, _worker_id: &WorkerId) -> Result<(), RuntimeError> { Ok(()) @@ -3162,6 +3413,7 @@ impl RuntimeState { worker_id, runtime_id: None, resource_key: None, + availability: protocol::subscription::SubscriptionWorkerAvailability::Observed, subject_revision: self .worker_subject_revisions .get(&worker.worker_id) @@ -3689,7 +3941,7 @@ struct RemovedInternalWorkerActivity { parent_session_id: Option, } -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] struct InternalWorkerActivityProjection { workers: BTreeMap, removed_workers: BTreeMap, @@ -3708,7 +3960,7 @@ impl InternalWorkerActivityProjection { } } -#[derive(Debug)] +#[derive(Debug, Clone)] struct WorkerRecord { worker_ref: WorkerRef, worker_id: WorkerId, @@ -4681,8 +4933,8 @@ mod tests { repository: WorkingDirectoryRepository { id: "repository-1".to_string(), provider: "git".to_string(), - source: workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::Ssh, + source: server_api::RepositorySource { + kind: server_api::RepositorySourceKind::Ssh, uri: "ssh://git@example.test/repo.git".to_string(), }, source_revision: 1, @@ -4705,7 +4957,7 @@ mod tests { }], host_trust_id: "host-trust-1".to_string(), host_trust_revision: 1, - access: workspace_api::RepositoryAccessMode::ReadOnly, + access: server_api::RepositoryAccessMode::ReadOnly, expires_at_epoch_seconds: u64::MAX, repository_id: "repository-1".to_string(), repository_source_fingerprint: "sha256:source".to_string(), @@ -4823,7 +5075,7 @@ mod tests { ], host_trust_id: "host-trust-1".to_string(), host_trust_revision: 1, - access: workspace_api::RepositoryAccessMode::ReadOnly, + access: server_api::RepositoryAccessMode::ReadOnly, expires_at_epoch_seconds: u64::MAX, repository_id: "repository-1".to_string(), repository_source_fingerprint: "sha256:source".to_string(), @@ -4912,8 +5164,8 @@ mod tests { repository: WorkingDirectoryRepository { id: "repository-1".to_string(), provider: "git".to_string(), - source: workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::Ssh, + source: server_api::RepositorySource { + kind: server_api::RepositorySourceKind::Ssh, uri: "ssh://git@example.test/repo.git".to_string(), }, source_revision: 1, @@ -4943,7 +5195,7 @@ mod tests { ], host_trust_id: "host-trust-1".to_string(), host_trust_revision: 1, - access: workspace_api::RepositoryAccessMode::ReadOnly, + access: server_api::RepositoryAccessMode::ReadOnly, expires_at_epoch_seconds: u64::MAX, repository_id: "repository-1".to_string(), repository_source_fingerprint: "sha256:source".to_string(), @@ -5095,6 +5347,7 @@ mod tests { stop_gate: Mutex>>, stop_count: Mutex, restore_result: Mutex>, + preflight_restore_result: Mutex>, restore_gate: Mutex>>, restore_count: Mutex, config_bundles: Mutex>>, @@ -5214,6 +5467,16 @@ mod tests { } } + fn preflight_restore( + &self, + _request: &WorkerExecutionRestoreRequest, + ) -> Result<(), WorkerExecutionResult> { + match self.preflight_restore_result.lock().unwrap().clone() { + Some(result) => Err(result), + None => Ok(()), + } + } + fn restore_worker( &self, request: WorkerExecutionRestoreRequest, @@ -6610,6 +6873,40 @@ mod tests { ); } + #[test] + fn session_observation_distinguishes_live_and_retained_without_restore() { + let (runtime, backend) = runtime_and_backend(); + let scope = scope("local", "server-a"); + let detail = runtime + .create_worker_scoped(&scope, task_request("observe without restore")) + .unwrap(); + + assert!(matches!( + runtime + .worker_session_scoped(&scope, &detail.worker_ref) + .unwrap(), + runtime_api::WorkerSessionAvailability::LiveProtocol + )); + + runtime + .send_protocol_method( + &detail.worker_ref, + Method::Shutdown { + command: test_command(), + }, + ) + .unwrap(); + let observation = runtime + .worker_session_scoped(&scope, &detail.worker_ref) + .unwrap(); + + assert!(matches!( + observation, + runtime_api::WorkerSessionAvailability::Unavailable { .. } + )); + assert_eq!(*backend.restore_count.lock().unwrap(), 0); + } + #[test] fn stopped_worker_rejects_input_until_explicitly_restored() { let (runtime, backend) = runtime_and_backend(); @@ -7326,6 +7623,72 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[test] + fn restore_preflight_rejection_is_typed_and_side_effect_free() { + let (runtime, backend) = runtime_and_backend(); + runtime.store_config_bundle(test_bundle()).unwrap(); + let created = runtime + .create_worker(task_request("restore preflight")) + .unwrap(); + runtime.stop_worker(&created.worker_ref, None).unwrap(); + let before = runtime.worker_detail(&created.worker_ref).unwrap(); + backend + .preflight_restore_result + .lock() + .unwrap() + .replace(WorkerExecutionResult::rejected( + WorkerExecutionOperation::Restore, + "profile is no longer permitted", + )); + + let result = runtime + .restore_worker_operation(&created.worker_ref) + .unwrap(); + + assert_eq!(result.state, WorkerRestoreState::Rejected); + assert!(result.worker.is_none()); + assert_eq!(*backend.restore_count.lock().unwrap(), 0); + assert_eq!(runtime.worker_detail(&created.worker_ref).unwrap(), before); + } + + #[test] + fn uncertain_restore_backend_outcome_requires_reconciliation() { + let (runtime, backend) = runtime_and_backend(); + runtime.store_config_bundle(test_bundle()).unwrap(); + let created = runtime + .create_worker(task_request("restore reconciliation")) + .unwrap(); + runtime.stop_worker(&created.worker_ref, None).unwrap(); + backend.restore_result.lock().unwrap().replace( + WorkerExecutionSpawnResult::ReconciliationRequired { + result: WorkerExecutionResult::errored( + WorkerExecutionOperation::Restore, + "controller handshake outcome was not observable", + ), + handle: None, + worker_state: None, + working_directory: None, + }, + ); + + let result = runtime + .restore_worker_operation(&created.worker_ref) + .unwrap(); + + assert_eq!(result.state, WorkerRestoreState::ReconciliationRequired); + assert!(result.worker.is_none()); + assert_eq!( + runtime.worker_detail(&created.worker_ref).unwrap().status, + WorkerStatus::Idle + ); + backend.restore_result.lock().unwrap().take(); + let retry = runtime + .restore_worker_operation(&created.worker_ref) + .unwrap(); + assert_eq!(retry.state, WorkerRestoreState::Accepted); + assert_eq!(*backend.restore_count.lock().unwrap(), 2); + } + #[cfg(feature = "fs-store")] #[test] fn fs_store_automatically_restores_every_active_lifecycle_state() { @@ -7661,7 +8024,7 @@ mod tests { #[cfg(feature = "fs-store")] #[test] - fn fs_store_stops_worker_and_reports_when_execution_restore_fails() { + fn fs_store_marks_worker_for_reconciliation_when_execution_restore_is_uncertain() { let root = fs_store_root("execution-restore-failed"); let runtime = Runtime::with_fs_store_and_execution_backend( crate::fs_store::FsRuntimeStoreOptions { @@ -7695,27 +8058,20 @@ mod tests { assert_eq!(*restoring_backend.restore_count.lock().unwrap(), 1); let restored_worker = restored.worker_detail(&worker.worker_ref).unwrap(); - assert_eq!(restored_worker.status, WorkerStatus::Stopped); - assert!( - restored - .diagnostics() - .unwrap() - .iter() - .any( - |diagnostic| diagnostic.code == "worker_execution_restore_failed" - && diagnostic.worker_ref.as_ref() == Some(&worker.worker_ref) - ) - ); - let err = restored + assert_eq!(restored_worker.status, WorkerStatus::Idle); + assert!(!restored_worker.execution_metadata_available); + restoring_backend.restore_result.lock().unwrap().take(); + let retry = restored + .restore_worker_operation(&worker.worker_ref) + .unwrap(); + assert_eq!(retry.state, WorkerRestoreState::Accepted); + assert_eq!(*restoring_backend.restore_count.lock().unwrap(), 2); + restored .send_input( &worker.worker_ref, - WorkerInput::user("after failed restore"), + WorkerInput::user("after reconciled restore"), ) - .unwrap_err(); - assert!(matches!( - err, - RuntimeError::WorkerExecutionUnavailable { .. } - )); + .unwrap(); let _ = std::fs::remove_dir_all(root); } diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index e850a4791..13807a326 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -18,13 +18,14 @@ use crate::auth::{BACKEND_RESOURCE_FETCH_PERMISSION, RuntimeIdentityMaterial}; use crate::catalog::{ CreateWorkerRequest, ProfileSourceArchiveSource, RepositoryRefObservation, RepositoryRefObservationRequest, WorkingDirectoryRepositoryAccessRequest, - WorkingDirectoryRequest, WorkingDirectoryStatus, + WorkingDirectoryRequest, WorkingDirectoryStatus, WorkingDirectoryStatusKind, }; use crate::config_bundle::{ConfigBundle, workspace_config_etag}; use crate::execution::{ WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionRestoreRequest, WorkerExecutionResult, WorkerExecutionSpawnRequest, - WorkerExecutionSpawnResult, WorkspaceConfigFetchRequest, WorkspaceConfigFetchResult, + WorkerExecutionSpawnResult, WorkerSessionObservationRequest, WorkspaceConfigFetchRequest, + WorkspaceConfigFetchResult, }; use crate::identity::WorkerRef; use crate::interaction::{WorkerInput, WorkerInputKind}; @@ -59,7 +60,9 @@ fn next_internal_command( .max(floor); Ok(WorkerCommandEnvelope::new(command_id)) } -use session_store::{CombinedStore, WorkerAggregateStore, WorkerSessionStore}; +use session_store::{ + CombinedStore, Store, WorkerAggregateStore, WorkerMetadataStore, WorkerSessionStore, +}; #[cfg(test)] use session_store::{FsStore, FsWorkerStore}; use tokio::runtime::Runtime; @@ -117,11 +120,26 @@ pub trait RuntimeWorkerFactory: Send + Sync + 'static { Err("Runtime Worker factory does not support Workspace Config fetching".to_string()) } + fn retained_session_snapshot( + &self, + _worker_ref: &WorkerRef, + ) -> Result + { + Err(session_store::RetainedSnapshotReadError::RetentionMissing) + } + async fn spawn_controller( &self, request: WorkerExecutionSpawnRequest, ) -> Result; + async fn preflight_restore( + &self, + _request: &WorkerExecutionRestoreRequest, + ) -> Result<(), String> { + Ok(()) + } + async fn restore_controller( &self, request: WorkerExecutionRestoreRequest, @@ -798,6 +816,22 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { fetch_workspace_config_http(&request, client).await } + fn retained_session_snapshot( + &self, + worker_ref: &WorkerRef, + ) -> Result + { + let Some(root) = self.worker_aggregate_root.as_ref() else { + return Err(session_store::RetainedSnapshotReadError::RetentionMissing); + }; + let worker_name = Self::runtime_worker_name_for_ref(worker_ref); + session_store::read_retained_session_snapshot( + &root.join(&worker_name), + &worker_name, + session_store::DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES, + ) + } + fn observe_workspace_prompt_projection( &self, projection: worker::WorkspacePromptProjection, @@ -1006,6 +1040,56 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { }) } + async fn preflight_restore( + &self, + request: &WorkerExecutionRestoreRequest, + ) -> Result<(), String> { + let worker_name = Self::runtime_worker_name_for_ref(&request.worker_ref); + let (mut manifest, _) = Self::restore_fallback_manifest(&worker_name)?; + bind_workspace_memory_settings(&mut manifest, &request.request)?; + let worker_aggregate_dir = self.worker_aggregate_dir(&request.worker_ref)?; + if !worker_aggregate_dir.is_dir() { + return Err("Persisted Worker aggregate metadata is unavailable".to_string()); + } + if !worker_aggregate_dir.join("session").is_dir() { + return Err("Persisted Worker Session metadata is unavailable".to_string()); + } + let metadata_store = WorkerAggregateStore::new(&worker_aggregate_dir, &worker_name) + .map_err(|error| format!("failed to read Worker aggregate metadata: {error}"))?; + let metadata = metadata_store + .read_by_name(&worker_name) + .map_err(|error| format!("failed to read Worker metadata: {error}"))? + .ok_or_else(|| "Persisted Worker metadata is unavailable".to_string())?; + if let Some(active) = metadata.active.as_ref() + && let Some(segment_id) = active.segment_id + { + let session_store = WorkerSessionStore::new(worker_aggregate_dir.join("session")) + .map_err(|error| format!("failed to read Worker Session store: {error}"))?; + if !session_store + .exists(active.session_id, segment_id) + .map_err(|error| format!("failed to inspect Worker Session segment: {error}"))? + { + return Err("Persisted Worker Session segment is unavailable".to_string()); + } + session_store + .read_all(active.session_id, segment_id) + .map_err(|error| format!("failed to read Worker Session segment: {error}"))?; + } + if request.request.workspace_api.is_some() + && metadata + .active + .as_ref() + .is_some_and(|active| active.segment_id.is_none()) + && request.config_bundle.is_none() + { + return Err( + "Pending Workspace Worker restore requires current profile launch authority" + .to_string(), + ); + } + Ok(()) + } + async fn restore_controller( &self, request: WorkerExecutionRestoreRequest, @@ -1314,6 +1398,7 @@ pub struct WorkerRuntimeExecutionBackend { working_directory_materializer: Option>, runtime: Mutex>, workers: Mutex>, + restore_lock: Mutex<()>, spawn_restore_timeout: Duration, } @@ -1342,6 +1427,7 @@ where working_directory_materializer: None, runtime: Mutex::new(Some(runtime)), workers: Mutex::new(HashMap::new()), + restore_lock: Mutex::new(()), spawn_restore_timeout: SPAWN_RESTORE_TASK_TIMEOUT, }) } @@ -1573,6 +1659,39 @@ where .unwrap_or_else(|message| WorkerExecutionResult::errored(operation, message)) } + fn retain_uncertain_unconnected_controller( + &self, + worker_ref: &crate::identity::WorkerRef, + handle: WorkerHandle, + shutdown: Arc>>, + tasks: RuntimeExecutionTaskScope, + worker_state: Arc>, + workspace_client: Option>, + ) -> Result { + let mut workers = self + .workers + .lock() + .map_err(|_| "worker adapter registry lock is poisoned".to_string())?; + if workers.contains_key(worker_ref) { + return Err("Worker already has a retained execution handle".to_string()); + } + workers.insert( + worker_ref.clone(), + RuntimeWorkerExecution { + handle, + shutdown, + shutdown_requested: Arc::new(AtomicBool::new(false)), + tasks, + worker_state, + workspace_client, + }, + ); + Ok(WorkerExecutionHandle::new( + worker_ref.clone(), + self.backend_id(), + )) + } + fn cleanup_unconnected_controller( &self, handle: &WorkerHandle, @@ -1622,11 +1741,19 @@ where working_directory: Option, workspace_client: Option>, ) -> WorkerExecutionSpawnResult { - let worker_state = Arc::new(RwLock::new(handle.shared_state.snapshot())); + #[cfg(feature = "ws-server")] + let streams = subscribe_worker_protocol_session(&handle); + #[cfg(feature = "ws-server")] + let worker_state_snapshot = match &streams.snapshot_event { + Event::Snapshot { state, .. } => state.clone(), + _ => unreachable!("Worker protocol subscription snapshot must be a snapshot event"), + }; + #[cfg(not(feature = "ws-server"))] + let worker_state_snapshot = handle.shared_state.snapshot(); + let worker_state = Arc::new(RwLock::new(worker_state_snapshot)); let tasks = RuntimeExecutionTaskScope::new(controller_task); #[cfg(feature = "ws-server")] { - let streams = subscribe_worker_protocol_session(&handle); let mut events = streams.events; let mut entry_events = streams.log_entries; let bridge_worker_state = worker_state.clone(); @@ -1670,15 +1797,50 @@ where }) { Ok(task) => task, Err(message) => { - let cleanup = self - .cleanup_unconnected_controller(&handle, &shutdown, &tasks, &worker_state) - .err() - .map(|cleanup| format!("; controller cleanup failed: {cleanup}")) - .unwrap_or_default(); - return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored( + let cleanup = self.cleanup_unconnected_controller( + &handle, + &shutdown, + &tasks, + &worker_state, + ); + let result = WorkerExecutionResult::errored( operation, - format!("{message}{cleanup}"), - )); + match &cleanup { + Ok(()) => message, + Err(cleanup) => { + format!("{message}; controller cleanup failed: {cleanup}") + } + }, + ); + return if operation == WorkerExecutionOperation::Restore { + match cleanup { + Ok(()) => WorkerExecutionSpawnResult::RolledBack(result), + Err(_) => { + let retained_state = + worker_state.read().ok().map(|state| state.clone()); + let retained_working_directory = + working_directory.as_ref().map(|binding| binding.status()); + let retained_handle = self + .retain_uncertain_unconnected_controller( + &worker_ref, + handle, + shutdown, + tasks, + worker_state, + workspace_client, + ) + .ok(); + WorkerExecutionSpawnResult::ReconciliationRequired { + result, + handle: retained_handle, + worker_state: retained_state, + working_directory: retained_working_directory, + } + } + } + } else { + WorkerExecutionSpawnResult::Errored(result) + }; } }; tasks.push("protocol bridge", bridge_task, true); @@ -1688,31 +1850,40 @@ where let _ = bridge_context; } - let mut workers = match self.workers.lock() { - Ok(workers) => workers, - Err(_) => { - let cleanup = self - .cleanup_unconnected_controller(&handle, &shutdown, &tasks, &worker_state) - .err() - .map(|cleanup| format!("; controller cleanup failed: {cleanup}")) - .unwrap_or_default(); - return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored( - operation, - format!("worker adapter registry lock is poisoned{cleanup}"), - )); - } - }; + let mut workers = self + .workers + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); if workers.contains_key(&worker_ref) { + let existing_worker_state = workers + .get(&worker_ref) + .and_then(|worker| worker.worker_state.read().ok().map(|state| state.clone())); + let existing_handle = WorkerExecutionHandle::new(worker_ref.clone(), self.backend_id()); drop(workers); - let cleanup = self - .cleanup_unconnected_controller(&handle, &shutdown, &tasks, &worker_state) - .err() - .map(|cleanup| format!("; controller cleanup failed: {cleanup}")) - .unwrap_or_default(); - return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::busy( + let cleanup = + self.cleanup_unconnected_controller(&handle, &shutdown, &tasks, &worker_state); + let result = WorkerExecutionResult::busy( operation, - format!("Worker is already connected to execution backend{cleanup}"), - )); + match &cleanup { + Ok(()) => "Worker is already connected to execution backend".to_string(), + Err(cleanup) => format!( + "Worker is already connected to execution backend; controller cleanup failed: {cleanup}" + ), + }, + ); + return if operation == WorkerExecutionOperation::Restore { + match cleanup { + Ok(()) => WorkerExecutionSpawnResult::RolledBack(result), + Err(_) => WorkerExecutionSpawnResult::ReconciliationRequired { + result, + handle: Some(existing_handle), + worker_state: existing_worker_state, + working_directory: None, + }, + } + } else { + WorkerExecutionSpawnResult::Rejected(result) + }; } let connected_worker_state = worker_state .read() @@ -1781,6 +1952,48 @@ where self.run_on_adapter_runtime(async move { factory.fetch_workspace_config(request).await }) } + fn worker_session( + &self, + request: WorkerSessionObservationRequest, + ) -> runtime_api::WorkerSessionAvailability { + match self.factory.retained_session_snapshot(&request.worker_ref) { + Ok(retained) => runtime_api::WorkerSessionAvailability::RetainedSnapshot { + identity: runtime_api::RetainedSessionIdentity { + session_id: retained.identity.session_id, + segment_id: retained.identity.segment_id, + entry_count: retained.identity.entry_count, + }, + snapshot: retained.snapshot, + }, + Err(error) => { + let reason = match error { + session_store::RetainedSnapshotReadError::RetentionMissing => { + runtime_api::WorkerSessionUnavailableReason::RetentionMissing + } + session_store::RetainedSnapshotReadError::ActivePointerMissing => { + runtime_api::WorkerSessionUnavailableReason::ActivePointerMissing + } + session_store::RetainedSnapshotReadError::MigrationRequired => { + runtime_api::WorkerSessionUnavailableReason::MigrationRequired + } + session_store::RetainedSnapshotReadError::CorruptLog => { + runtime_api::WorkerSessionUnavailableReason::CorruptLog + } + session_store::RetainedSnapshotReadError::StorageUnavailable => { + runtime_api::WorkerSessionUnavailableReason::StorageUnavailable + } + session_store::RetainedSnapshotReadError::SnapshotTooLarge => { + runtime_api::WorkerSessionUnavailableReason::SnapshotTooLarge + } + }; + runtime_api::WorkerSessionAvailability::Unavailable { + reason, + message: error.to_string(), + } + } + } + } + fn observe_workspace_prompt_projection( &self, projection: worker::WorkspacePromptProjection, @@ -1888,11 +2101,15 @@ where } fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult { + let _start_guard = self + .restore_lock + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); if self .workers .lock() - .map(|workers| workers.contains_key(&request.worker_ref)) - .unwrap_or(false) + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .contains_key(&request.worker_ref) { return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::busy( WorkerExecutionOperation::Spawn, @@ -1999,10 +2216,111 @@ where ) } + fn preflight_restore( + &self, + request: &WorkerExecutionRestoreRequest, + ) -> Result<(), WorkerExecutionResult> { + let workers = self + .workers + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if workers.contains_key(&request.worker_ref) { + return Err(WorkerExecutionResult::busy( + WorkerExecutionOperation::Restore, + "Worker is already connected to execution backend", + )); + } + drop(workers); + + if let Some(previous) = request.previous_working_directory.as_ref() { + let materializer = self + .working_directory_materializer + .as_ref() + .ok_or_else(|| { + WorkerExecutionResult::rejected( + WorkerExecutionOperation::Restore, + "Persisted Worker Workdir binding cannot be restored by this Runtime", + ) + })?; + let current = materializer + .working_directory_status(&previous.summary.working_directory_id) + .map_err(|message| { + WorkerExecutionResult::rejected( + WorkerExecutionOperation::Restore, + format!("Persisted Worker Workdir is unavailable: {message}"), + ) + })?; + if current.summary.status != WorkingDirectoryStatusKind::Active { + return Err(WorkerExecutionResult::rejected( + WorkerExecutionOperation::Restore, + "Persisted Worker Workdir is not active", + )); + } + } + if request.previous_working_directory.is_none() + && request.request.working_directory_request.is_some() + { + return Err(WorkerExecutionResult::rejected( + WorkerExecutionOperation::Restore, + "Persisted Worker Workdir allocation is unavailable", + )); + } + if request.previous_working_directory.is_none() + && request.request.working_directory.is_some() + && self.working_directory_materializer.is_none() + { + return Err(WorkerExecutionResult::rejected( + WorkerExecutionOperation::Restore, + "Persisted Worker Workdir claim cannot be restored by this Runtime", + )); + } + if let Some(workspace_api) = request.request.workspace_api.as_ref() { + let Some(scope) = request.workspace_scope.as_ref() else { + return Err(WorkerExecutionResult::rejected( + WorkerExecutionOperation::Restore, + "Persisted Workspace restore authorization is unavailable", + )); + }; + if scope.workspace_id != workspace_api.workspace_id { + return Err(WorkerExecutionResult::rejected( + WorkerExecutionOperation::Restore, + "Persisted Workspace restore authorization does not match Worker authority", + )); + } + } + + let factory = Arc::clone(&self.factory); + let request = request.clone(); + self.run_on_adapter_runtime(async move { factory.preflight_restore(&request).await }) + .map_err(|message| { + WorkerExecutionResult::rejected(WorkerExecutionOperation::Restore, message) + })?; + Ok(()) + } + fn restore_worker( &self, mut request: WorkerExecutionRestoreRequest, ) -> WorkerExecutionSpawnResult { + // Serialize backend restore starts so duplicate detection is completed + // before any controller or bridge side effect. Runtime also serializes + // per Worker; this guard protects direct/concurrent backend callers. + let _restore_guard = self + .restore_lock + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let workers = self + .workers + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if workers.contains_key(&request.worker_ref) { + return WorkerExecutionSpawnResult::Rejected(WorkerExecutionResult::busy( + WorkerExecutionOperation::Restore, + "Worker is already connected to execution backend", + )); + } + drop(workers); + let working_directory = match request.previous_working_directory.clone() { Some(status) => { let Some(materializer) = self.working_directory_materializer.as_ref() else { @@ -2080,10 +2398,15 @@ where let controller = match restore_result { Ok(controller) => controller, Err(message) => { - return WorkerExecutionSpawnResult::Errored(WorkerExecutionResult::errored( - WorkerExecutionOperation::Restore, - message, - )); + return WorkerExecutionSpawnResult::ReconciliationRequired { + result: WorkerExecutionResult::errored( + WorkerExecutionOperation::Restore, + message, + ), + handle: None, + worker_state: None, + working_directory: None, + }; } }; @@ -2122,7 +2445,6 @@ where Method::NotifyTracked { notification_request_id: notification_request_id.clone(), message: input.content, - auto_run: true, source: protocol::AuthenticatedInputSource::Backend { operation_id: notification_request_id, }, @@ -3265,8 +3587,8 @@ mod tests { repository: WorkingDirectoryRepository { id: "repo-main".to_string(), provider: "git".to_string(), - source: workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::LocalPath, + source: server_api::RepositorySource { + kind: server_api::RepositorySourceKind::LocalPath, uri: repo.display().to_string(), }, source_revision: 1, diff --git a/crates/worker-runtime/src/worker_source.rs b/crates/worker-runtime/src/worker_source.rs index f57e3a9e4..f42aba2a3 100644 --- a/crates/worker-runtime/src/worker_source.rs +++ b/crates/worker-runtime/src/worker_source.rs @@ -455,7 +455,7 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { fn list_workspace_workers( &self, request: worker::WorkspaceWorkerDiscoveryRequest, - ) -> Result { + ) -> Result { let mut path = format!( "/api/w/{}/worker-discovery/workers?limit={}", self.workspace_id, request.limit diff --git a/crates/worker-runtime/src/working_directory.rs b/crates/worker-runtime/src/working_directory.rs index 8d6aac3b9..79cb50190 100644 --- a/crates/worker-runtime/src/working_directory.rs +++ b/crates/worker-runtime/src/working_directory.rs @@ -842,8 +842,8 @@ impl RuntimeGitMaterializer { binding.command_environment.insert( "YOI_REPOSITORY_ACCESS".to_string(), match access.access { - workspace_api::RepositoryAccessMode::ReadOnly => "read_only", - workspace_api::RepositoryAccessMode::ReadWrite => "read_write", + server_api::RepositoryAccessMode::ReadOnly => "read_only", + server_api::RepositoryAccessMode::ReadWrite => "read_write", } .to_string(), ); @@ -884,7 +884,7 @@ impl RuntimeGitMaterializer { Self::validate_plain_http_source(request)?; if matches!( request.repository.source.kind, - workspace_api::RepositorySourceKind::Https | workspace_api::RepositorySourceKind::Ssh + server_api::RepositorySourceKind::Https | server_api::RepositorySourceKind::Ssh ) { validate_remote_source_uri(request)?; let materialization = request.materialization.as_ref().ok_or_else(|| { @@ -906,10 +906,10 @@ impl RuntimeGitMaterializer { } } match request.repository.source.kind { - workspace_api::RepositorySourceKind::LocalPath - | workspace_api::RepositorySourceKind::File - | workspace_api::RepositorySourceKind::Https => {} - workspace_api::RepositorySourceKind::Ssh => { + server_api::RepositorySourceKind::LocalPath + | server_api::RepositorySourceKind::File + | server_api::RepositorySourceKind::Https => {} + server_api::RepositorySourceKind::Ssh => { let ssh = request .materialization .as_ref() @@ -922,7 +922,7 @@ impl RuntimeGitMaterializer { })?; validate_ssh_materialization_access(ssh)?; } - workspace_api::RepositorySourceKind::Invalid => { + server_api::RepositorySourceKind::Invalid => { return Err(WorkingDirectoryDiagnostic::new( "working_directory_repository_source_invalid", "configured Repository source is invalid and cannot be materialized", @@ -939,7 +939,7 @@ impl RuntimeGitMaterializer { ) -> Result { let mut request = request.clone(); if request.repository.provider != "git" - || request.repository.source.kind != workspace_api::RepositorySourceKind::Ssh + || request.repository.source.kind != server_api::RepositorySourceKind::Ssh { return Ok(request); } @@ -1911,7 +1911,7 @@ struct RepositorySshCommandPolicy { username: Option, port: Option, repository_path: String, - access: workspace_api::RepositoryAccessMode, + access: server_api::RepositoryAccessMode, } impl RepositorySshCommandPolicy { @@ -2053,9 +2053,7 @@ fn repository_command_matches(command: &str, policy: &RepositorySshCommandPolicy } else { return false; }; - if operation == "receive-pack" - && policy.access != workspace_api::RepositoryAccessMode::ReadWrite - { + if operation == "receive-pack" && policy.access != server_api::RepositoryAccessMode::ReadWrite { return false; } let argument = argument @@ -2218,7 +2216,7 @@ impl RepositoryCommandAccess { runtime_root: &Path, request: &WorkingDirectoryRequest, ) -> Result, WorkingDirectoryDiagnostic> { - if request.repository.source.kind != workspace_api::RepositorySourceKind::Ssh { + if request.repository.source.kind != server_api::RepositorySourceKind::Ssh { return Ok(None); } let Some(ssh) = request @@ -2403,7 +2401,7 @@ fn validate_ssh_materialization_access( fn validate_remote_source_uri( request: &WorkingDirectoryRequest, ) -> Result<(), WorkingDirectoryDiagnostic> { - if request.repository.source.kind == workspace_api::RepositorySourceKind::Ssh { + if request.repository.source.kind == server_api::RepositorySourceKind::Ssh { let source = parse_repository_ssh_source(&request.repository.source.uri).ok_or_else(|| { WorkingDirectoryDiagnostic::new( @@ -2441,7 +2439,7 @@ fn validate_remote_source_uri( "remote Repository source URI is invalid", ) })?; - if request.repository.source.kind != workspace_api::RepositorySourceKind::Https { + if request.repository.source.kind != server_api::RepositorySourceKind::Https { return Ok(()); } if url.scheme() != "https" @@ -2512,7 +2510,7 @@ fn repository_git_command( let mut command = isolated_git_command(); let file_policy = if matches!( request.repository.source.kind, - workspace_api::RepositorySourceKind::LocalPath | workspace_api::RepositorySourceKind::File + server_api::RepositorySourceKind::LocalPath | server_api::RepositorySourceKind::File ) { "always" } else { @@ -2589,7 +2587,7 @@ fn test_repository_git_invocation_count() -> usize { fn run_repository_git_stdout( mut command: Command, - source_kind: workspace_api::RepositorySourceKind, + source_kind: server_api::RepositorySourceKind, ) -> Result { #[cfg(test)] TEST_REPOSITORY_GIT_INVOCATIONS.with(|count| count.set(count.get().saturating_add(1))); @@ -2837,10 +2835,10 @@ fn resolve_cloned_commit( fn apply_repository_access_policy( repository_root: &Path, - access: workspace_api::RepositoryAccessMode, + access: server_api::RepositoryAccessMode, ) -> Result<(), WorkingDirectoryDiagnostic> { match access { - workspace_api::RepositoryAccessMode::ReadOnly => { + server_api::RepositoryAccessMode::ReadOnly => { let mut disable_push = isolated_git_command(); disable_push.arg("-C").arg(repository_root).args([ "remote", @@ -2851,7 +2849,7 @@ fn apply_repository_access_policy( ]); run_repository_git(disable_push, "working_directory_repository_policy_failed") } - workspace_api::RepositoryAccessMode::ReadWrite => { + server_api::RepositoryAccessMode::ReadWrite => { if git_stdout( repository_root, ["config", "--get-all", "remote.origin.pushurl"], @@ -3122,7 +3120,7 @@ mod tests { }], host_trust_id: "trust-1".to_string(), host_trust_revision: 4, - access: workspace_api::RepositoryAccessMode::ReadOnly, + access: server_api::RepositoryAccessMode::ReadOnly, expires_at_epoch_seconds: u64::MAX, repository_id: "repo-main".to_string(), repository_source_fingerprint: "sha256:test".to_string(), @@ -3310,8 +3308,8 @@ mod tests { repository: WorkingDirectoryRepository { id: "repo-main".to_string(), provider: "git".to_string(), - source: workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::LocalPath, + source: server_api::RepositorySource { + kind: server_api::RepositorySourceKind::LocalPath, uri: repo.display().to_string(), }, source_revision: 1, @@ -3594,7 +3592,7 @@ mod tests { assert!(!runtime_root.path().join(".repository-cache").exists()); let mut file_request = request(repo.path()); - file_request.repository.source.kind = workspace_api::RepositorySourceKind::File; + file_request.repository.source.kind = server_api::RepositorySourceKind::File; file_request.repository.source.uri = format!("file://{}", repo.path().display()); file_request.repository.source_revision = 2; file_request.repository.source_fingerprint = "sha256:file-source".to_string(); @@ -3723,7 +3721,7 @@ mod tests { ], host_trust_id: "trust-1".to_string(), host_trust_revision: 1, - access: workspace_api::RepositoryAccessMode::ReadWrite, + access: server_api::RepositoryAccessMode::ReadWrite, expires_at_epoch_seconds: u64::MAX, repository_id: "repo-main".to_string(), repository_source_fingerprint: "sha256:test".to_string(), @@ -3735,8 +3733,8 @@ mod tests { }), }); let mut ssh_operation = request.clone(); - ssh_operation.repository.source = workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::Ssh, + ssh_operation.repository.source = server_api::RepositorySource { + kind: server_api::RepositorySourceKind::Ssh, uri: "ssh://git@example.test/repo.git".to_string(), }; let command_access = RepositoryCommandAccess::prepare(runtime_root.path(), &ssh_operation) @@ -3810,8 +3808,8 @@ mod tests { request.backend_workdir_id = Some(working_directory_id.clone()); request.materialization.as_mut().unwrap().ssh = None; let mut authorized_ssh_request = request.clone(); - authorized_ssh_request.repository.source = workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::Ssh, + authorized_ssh_request.repository.source = server_api::RepositorySource { + kind: server_api::RepositorySourceKind::Ssh, uri: "ssh://git@example.test/repo.git".to_string(), }; assert!( @@ -3910,7 +3908,7 @@ mod tests { let mut rotated = initial_materialization.clone(); rotated.operation_id = "operation-agent-rotated".to_string(); rotated.ssh.as_mut().unwrap().credential_candidates[0].credential_revision = 2; - rotated.ssh.as_mut().unwrap().access = workspace_api::RepositoryAccessMode::ReadOnly; + rotated.ssh.as_mut().unwrap().access = server_api::RepositoryAccessMode::ReadOnly; materializer .authorize_repository_access(&WorkingDirectoryRepositoryAccessRequest { working_directory_id: id.clone(), @@ -4028,7 +4026,7 @@ mod tests { let mut read_write = rotated; read_write.operation_id = "operation-agent-read-write".to_string(); read_write.ssh.as_mut().unwrap().credential_candidates[0].credential_revision = 3; - read_write.ssh.as_mut().unwrap().access = workspace_api::RepositoryAccessMode::ReadWrite; + read_write.ssh.as_mut().unwrap().access = server_api::RepositoryAccessMode::ReadWrite; let read_write_command_policy = RepositorySshCommandPolicy::from_access(read_write.ssh.as_ref().unwrap()).unwrap(); materializer @@ -4099,7 +4097,7 @@ mod tests { }], host_trust_id: "trust-1".to_string(), host_trust_revision: 1, - access: workspace_api::RepositoryAccessMode::ReadOnly, + access: server_api::RepositoryAccessMode::ReadOnly, expires_at_epoch_seconds: u64::MAX, repository_id: "repo-main".to_string(), repository_source_fingerprint: "sha256:test".to_string(), @@ -4251,8 +4249,8 @@ mod tests { assert_eq!(policy.repository_path, "team/repo.git"); let mut request = request(Path::new(".")); - request.repository.source = workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::Ssh, + request.repository.source = server_api::RepositorySource { + kind: server_api::RepositorySourceKind::Ssh, uri: uri.to_string(), }; request.materialization = Some(crate::catalog::RepositoryMaterializationContext { @@ -4294,8 +4292,8 @@ mod tests { }; let mut https = request(repo.path()); - https.repository.source = workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::Https, + https.repository.source = server_api::RepositorySource { + kind: server_api::RepositorySourceKind::Https, uri: "https://token@example.test/repo.git".to_string(), }; https.materialization = Some(context(None)); @@ -4330,15 +4328,15 @@ mod tests { ); for (offset, kind) in [ - workspace_api::RepositorySourceKind::LocalPath, - workspace_api::RepositorySourceKind::File, - workspace_api::RepositorySourceKind::Https, + server_api::RepositorySourceKind::LocalPath, + server_api::RepositorySourceKind::File, + server_api::RepositorySourceKind::Https, ] .into_iter() .enumerate() { let mut mismatched_http = request(repo.path()); - mismatched_http.repository.source = workspace_api::RepositorySource { + mismatched_http.repository.source = server_api::RepositorySource { kind, uri: "http://example.test/repo.git".to_string(), }; @@ -4358,8 +4356,8 @@ mod tests { } let mut ssh = request(repo.path()); - ssh.repository.source = workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::Ssh, + ssh.repository.source = server_api::RepositorySource { + kind: server_api::RepositorySourceKind::Ssh, uri: "ssh://git@example.test/repo.git".to_string(), }; ssh.materialization = Some(context(Some( @@ -4371,7 +4369,7 @@ mod tests { }], host_trust_id: "trust-1".to_string(), host_trust_revision: 1, - access: workspace_api::RepositoryAccessMode::ReadOnly, + access: server_api::RepositoryAccessMode::ReadOnly, expires_at_epoch_seconds: u64::MAX, repository_id: "repo-main".to_string(), repository_source_fingerprint: "sha256:test".to_string(), @@ -4396,8 +4394,8 @@ mod tests { let runtime_root = tempfile::tempdir().unwrap(); let materializer = RuntimeGitMaterializer::new(runtime_root.path()); let mut remote = request(Path::new(".")); - remote.repository.source = workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::Ssh, + remote.repository.source = server_api::RepositorySource { + kind: server_api::RepositorySourceKind::Ssh, uri: "ssh://git@example.invalid/repo.git".to_string(), }; remote.materialization = Some(crate::catalog::RepositoryMaterializationContext { diff --git a/crates/worker-runtime/src/workspace_issuer.rs b/crates/worker-runtime/src/workspace_issuer.rs index dd4a05c19..5e5bdaaec 100644 --- a/crates/worker-runtime/src/workspace_issuer.rs +++ b/crates/worker-runtime/src/workspace_issuer.rs @@ -8,8 +8,8 @@ use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use ring::signature::Ed25519KeyPair; use serde::{Deserialize, Serialize}; +use server_api::WorkspacePublicIdentityBundle; use sha2::{Digest, Sha256}; -use workspace_api::WorkspacePublicIdentityBundle; use crate::auth::RuntimeAuthError; diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index 64d1be223..dacab4226 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -28,7 +28,7 @@ toml = { workspace = true } tracing = { workspace = true } tools = { workspace = true } workdir = { workspace = true } -workspace-api = { workspace = true } +server-api = { workspace = true } minijinja = "2.19.0" chrono = "0.4" config-source = { path = "../config-source" } diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index ebcb5c62c..2ea198113 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -2,6 +2,7 @@ use std::collections::VecDeque; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::Ordering; +use std::time::Duration; use agen::EngineError; use agen::llm_client::client::LlmClient; @@ -24,7 +25,9 @@ use crate::shutdown_after_idle::{ }; use crate::spawn::registry::SpawnedWorkerRegistry; use crate::spawn::tool::sub_worker_spawn_tool; -use crate::worker::{SystemItemCommitter, Worker, WorkerError, WorkerRunResult}; +use crate::worker::{ + PendingSubmissionHandle, SystemItemCommitter, Worker, WorkerError, WorkerRunResult, +}; use protocol::{ AlertLevel, AlertSource, CommandEvent as ProtocolCommandEvent, CommandSnapshot as ProtocolCommandSnapshot, CommandStatus as ProtocolCommandStatus, @@ -307,7 +310,6 @@ enum PendingRun { /// committed at the start of `worker.run_for_notification`. RunForNotification { invoke_kind: protocol::InvokeKind, - notification_request_id: Option, }, Resume, } @@ -336,7 +338,6 @@ fn durable_parent_notification_target let Method::NotifyTracked { notification_request_id, message, - auto_run, source, } = method else { @@ -344,21 +345,14 @@ fn durable_parent_notification_target }; let (source_namespace, provenance) = resolved_input_source(&pending_submissions, &source); match pending_submissions.accept_notification_from_source( - notification_request_id.clone(), + notification_request_id, message, - source_namespace.clone(), + source_namespace, provenance, - auto_run, ) { - Ok(_) if !auto_run => { - stage_pending_notification( - &pending_submissions, - ¬ify_buffer, - &source_namespace, - ¬ification_request_id, - ); + Ok(_) => { + stage_pending_notifications(&pending_submissions, ¬ify_buffer); } - Ok(_) => {} Err(error) => { tracing::warn!(%error, "failed to durably accept SubWorker notification"); } @@ -366,69 +360,57 @@ fn durable_parent_notification_target })) } -fn stage_pending_notification( +fn stage_pending_notifications( pending_submissions: &crate::worker::PendingSubmissionHandle, notify_buffer: &NotifyBuffer, - source_namespace: &str, - notification_request_id: &str, -) -> bool { - let Some(notification) = - pending_submissions.prepare_notification(source_namespace, notification_request_id) - else { - return false; - }; - let extension = pending_submissions.notification_activation_extension(); - notify_buffer.push_durable_notify( - notification.message, - notification.auto_run, - notification.provenance, - extension, - ); - true +) -> usize { + let notifications = pending_submissions.prepare_notification_batch(); + if notifications.is_empty() { + return 0; + } + let count = notifications.len(); + for (notification, extension) in notifications { + notify_buffer.push_durable_notify(notification.message, notification.provenance, extension); + } + count } -fn stage_oldest_passive_notification( +fn notification_coalesce_remaining( pending_submissions: &crate::worker::PendingSubmissionHandle, notify_buffer: &NotifyBuffer, -) -> bool { - pending_submissions - .next_passive_notification_identity() - .is_some_and(|(source_namespace, request_id)| { - stage_pending_notification( - pending_submissions, - notify_buffer, - &source_namespace, - &request_id, - ) - }) + delay: Duration, +) -> Option { + let Some(accepted_at_ms) = pending_submissions.oldest_pending_notification_accepted_at_ms() + else { + return notify_buffer + .has_notification_pending() + .then_some(Duration::ZERO); + }; + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + let elapsed = Duration::from_millis(now_ms.saturating_sub(accepted_at_ms)); + Some(delay.saturating_sub(elapsed)) } fn prepare_pending_run( pending_submissions: &crate::worker::PendingSubmissionHandle, notify_buffer: &NotifyBuffer, fence: Option<(u64, &str)>, + allow_notification_run: bool, ) -> Result, crate::worker::PendingSubmissionError> { - let staged_passive_notification = pending_submissions.activating_passive_notification_id(); Ok(match pending_submissions.prepare_next_activation(fence)? { Some(crate::worker::PendingActivation::Submission(submission)) => { - if staged_passive_notification.is_some() { - let extension = pending_submissions.notification_activation_extension(); - debug_assert!(notify_buffer.replace_durable_notification_extension(extension)); - } + stage_pending_notifications(pending_submissions, notify_buffer); Some(PendingRun::Submit(submission)) } - Some(crate::worker::PendingActivation::Notification(notification)) => { - let extension = pending_submissions.notification_activation_extension(); - let notification_request_id = notification.notification_request_id.clone(); - notify_buffer.push_durable_notify( - notification.message, - notification.auto_run, - notification.provenance, - extension, - ); + None if allow_notification_run + && (stage_pending_notifications(pending_submissions, notify_buffer) > 0 + || notify_buffer.has_notification_pending()) => + { Some(PendingRun::RunForNotification { invoke_kind: protocol::InvokeKind::Notify, - notification_request_id: Some(notification_request_id), }) } None => None, @@ -702,7 +684,6 @@ impl WorkerController { worker.push_notify( "Restored Worker state contained unreachable delegated child Workers; their delegated write scopes were reclaimed before resume." .to_string(), - false, ); } @@ -1510,6 +1491,153 @@ where Ok(workdir_for_view) } +fn durably_accept_method_while_busy( + method: Method, + pending_submissions: &PendingSubmissionHandle, + working_event_tx: &broadcast::Sender, +) -> Option +where + St: session_store::Store + Clone, +{ + match method { + Method::Submit { + submission_request_id, + input, + } => { + let request_id = submission_request_id.clone(); + match pending_submissions.accept_from_source( + submission_request_id, + input, + pending_submissions.direct_client_namespace(), + session_store::LoggedSessionHistoryOrigin::LegacyUnknown, + false, + ) { + Ok(acceptance) => { + let _ = working_event_tx.send(Event::SubmissionAccepted { + submission_request_id: acceptance.submission_request_id, + submission_id: acceptance.submission_id, + disposition: acceptance.disposition, + }); + } + Err(error) => { + let _ = working_event_tx.send(Event::SubmissionRejected { + submission_request_id: request_id, + message: error.to_string(), + }); + } + } + None + } + Method::SubmitTracked { + submission_request_id, + input, + source, + } => { + let request_id = submission_request_id.clone(); + let (source_namespace, provenance) = + resolved_input_source(pending_submissions, &source); + match pending_submissions.accept_from_source( + submission_request_id, + input, + source_namespace, + provenance, + false, + ) { + Ok(acceptance) => { + let _ = working_event_tx.send(Event::SubmissionAccepted { + submission_request_id: acceptance.submission_request_id, + submission_id: acceptance.submission_id, + disposition: acceptance.disposition, + }); + } + Err(error) => { + let _ = working_event_tx.send(Event::SubmissionRejected { + submission_request_id: request_id, + message: error.to_string(), + }); + } + } + None + } + Method::Notify { + notification_request_id, + message, + } => { + if let Err(error) = pending_submissions.accept_notification_from_source( + notification_request_id, + message, + pending_submissions.direct_client_namespace(), + session_store::LoggedSessionHistoryOrigin::LegacyUnknown, + ) { + let _ = working_event_tx.send(Event::Error { + code: ErrorCode::InvalidRequest, + message: error.to_string(), + }); + } + None + } + Method::NotifyTracked { + notification_request_id, + message, + source, + } => { + let (source_namespace, provenance) = + resolved_input_source(pending_submissions, &source); + if let Err(error) = pending_submissions.accept_notification_from_source( + notification_request_id, + message, + source_namespace, + provenance, + ) { + let _ = working_event_tx.send(Event::Error { + code: ErrorCode::InvalidRequest, + message: error.to_string(), + }); + } + None + } + method => Some(method), + } +} + +fn reject_method_while_attention_locked( + method: &Method, + working_event_tx: &broadcast::Sender, + message: &str, +) -> bool { + match method { + Method::Shutdown { .. } | Method::ListPendingSubmissions => false, + Method::Submit { + submission_request_id, + .. + } + | Method::SubmitTracked { + submission_request_id, + .. + } => { + let _ = working_event_tx.send(Event::SubmissionRejected { + submission_request_id: submission_request_id.clone(), + message: message.to_owned(), + }); + true + } + Method::Notify { .. } | Method::NotifyTracked { .. } => { + let _ = working_event_tx.send(Event::Error { + code: ErrorCode::InvalidRequest, + message: message.to_owned(), + }); + true + } + _ => { + let _ = working_event_tx.send(Event::Error { + code: ErrorCode::InvalidRequest, + message: message.to_owned(), + }); + true + } + } +} + /// Idle/Paused event loop. Each iteration either fires a staged /// `PendingRun` (delegating to [`drive_turn`] for the Running phase) or /// waits for the next `Method`. Method handlers stop at "update state + @@ -1556,9 +1684,9 @@ async fn controller_loop( discovery_cwd, spawned_registry.clone(), ); + let notification_coalesce_delay = worker.notification_coalesce_delay(); let pending_submissions = worker.pending_submission_handle(); - stage_oldest_passive_notification(&pending_submissions, ¬ify_buffer); - let mut pending = match prepare_pending_run(&pending_submissions, ¬ify_buffer, None) { + let mut pending = match prepare_pending_run(&pending_submissions, ¬ify_buffer, None, false) { Ok(pending) => pending, Err(error) => { let _ = working_event_tx.send(Event::Error { @@ -1572,9 +1700,19 @@ async fn controller_loop( let mut deferred_methods = VecDeque::new(); 'controller: loop { + if worker.has_failed_segment_activation() || worker.has_pending_compaction_cleanup() { + // A queued activation may have been accepted while the failing turn + // was still running. Preserve its durable queue entry, but never + // start it while repair/cleanup attention is required. + pending = None; + } // here so the status flip → drive_turn → finish sequence lives // in one place, regardless of which Method caused it. if let Some(run) = pending.take() { + // A submit/resume/run always provides an immediate model boundary, + // so any coalescing window ends and the whole FIFO notification + // batch is injected into that run. + stage_pending_notifications(&pending_submissions, ¬ify_buffer); // Cancellation is meaningful only for an accepted running turn. Clear // idle/stale signals before the status flip; any Cancel/Pause received // after this point is delivered to the turn and must not be discarded by @@ -1595,15 +1733,6 @@ async fn controller_loop( ) .await; } - let notification_request_id = match &run { - PendingRun::RunForNotification { - notification_request_id, - .. - } => notification_request_id.clone(), - _ => None, - }; - let passive_notification_request_id = - pending_submissions.activating_passive_notification_id(); let (mut new_status, shutdown, may_drain_pending) = match run { PendingRun::Submit(submission) => { let (input_commit_tx, input_commit_rx) = oneshot::channel(); @@ -1673,28 +1802,17 @@ async fn controller_loop( .await } }; - if let Some(notification_request_id) = - notification_request_id.or(passive_notification_request_id) - { - pending_submissions.finish_notification_activation(¬ification_request_id); - stage_oldest_passive_notification(&pending_submissions, ¬ify_buffer); + if notify_buffer.is_empty() { + pending_submissions.finish_notification_batch(); } if !shutdown && may_drain_pending && new_status == WorkerStatus::Idle { - match prepare_pending_run(&pending_submissions, ¬ify_buffer, None) { + match prepare_pending_run(&pending_submissions, ¬ify_buffer, None, false) { Ok(Some(next)) => { pending = Some(next); new_status = WorkerStatus::Running; } - Ok(None) => { - if notify_buffer.has_auto_run_pending() { - pending = Some(PendingRun::RunForNotification { - invoke_kind: protocol::InvokeKind::Notify, - notification_request_id: None, - }); - new_status = WorkerStatus::Running; - } - } + Ok(None) => {} Err(error) => { let _ = working_event_tx.send(Event::Error { code: ErrorCode::Internal, @@ -1712,6 +1830,16 @@ async fn controller_loop( ) .await; if shutdown { + while let Err(error) = worker + .finish_pending_compaction_cleanup_for_shutdown() + .await + { + let _ = working_event_tx.send(Event::Error { + code: worker_error_code(&error), + message: error.to_string(), + }); + tokio::time::sleep(Duration::from_millis(50)).await; + } let _ = working_event_tx.send(Event::Shutdown); break; } @@ -1722,15 +1850,120 @@ async fn controller_loop( continue; } + let notification_delay = (shared_state.catalog_status() == WorkerStatus::Idle) + .then(|| { + notification_coalesce_remaining( + &pending_submissions, + ¬ify_buffer, + notification_coalesce_delay, + ) + }) + .flatten(); let method = if let Some(method) = deferred_methods.pop_front() { method + } else if let Some(remaining) = notification_delay { + tokio::select! { + method = method_rx.recv() => match method { + Some(method) => method, + None => { + while let Err(error) = + worker.finish_pending_compaction_cleanup_for_shutdown().await + { + tracing::warn!(error = %error, "shutdown cleanup retry failed"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + break 'controller; + } + }, + _ = notify_buffer.notified() => continue, + _ = tokio::time::sleep(remaining) => { + match prepare_pending_run( + &pending_submissions, + ¬ify_buffer, + None, + true, + ) { + Ok(Some(next)) => pending = Some(next), + Ok(None) => {} + Err(error) => { + let _ = working_event_tx.send(Event::Error { + code: ErrorCode::Internal, + message: error.to_string(), + }); + } + } + continue; + } + } } else { - match method_rx.recv().await { - Some(method) => method, - None => break, + tokio::select! { + method = method_rx.recv() => match method { + Some(method) => method, + None => { + while let Err(error) = + worker.finish_pending_compaction_cleanup_for_shutdown().await + { + tracing::warn!(error = %error, "shutdown cleanup retry failed"); + tokio::time::sleep(Duration::from_millis(50)).await; + } + break 'controller; + } + }, + _ = notify_buffer.notified() => continue, } }; + if worker.has_pending_compaction_cleanup() { + match worker.retry_pending_compaction_cleanup().await { + Ok(()) => { + set_controller_state( + &shared_state, + &runtime_dir, + &working_event_tx, + WorkerState::Idle, + ) + .await; + } + Err(error) => { + if matches!(&method, Method::Shutdown { .. }) { + if let Err(error) = worker + .finish_pending_compaction_cleanup_for_shutdown() + .await + { + let _ = working_event_tx.send(Event::Error { + code: worker_error_code(&error), + message: error.to_string(), + }); + continue; + } + } else { + let message = error.to_string(); + let _ = working_event_tx.send(Event::Error { + code: worker_error_code(&error), + message: message.clone(), + }); + if reject_method_while_attention_locked( + &method, + &working_event_tx, + &message, + ) { + continue; + } + } + } + } + } + + if worker.has_failed_segment_activation() + && reject_method_while_attention_locked( + &method, + &working_event_tx, + "Segment activation is incomplete; restore or shut down the Worker", + ) + { + continue; + } + match method { Method::Submit { submission_request_id, @@ -1799,37 +2032,15 @@ async fn controller_loop( Method::Notify { notification_request_id, message, - auto_run, } => { - let request_id = notification_request_id.clone(); let source_namespace = pending_submissions.direct_client_namespace(); match pending_submissions.accept_notification_from_source( notification_request_id, message, source_namespace.clone(), session_store::LoggedSessionHistoryOrigin::LegacyUnknown, - auto_run, ) { - Ok(_) if auto_run => { - match prepare_pending_run(&pending_submissions, ¬ify_buffer, None) { - Ok(Some(next)) => pending = Some(next), - Ok(None) => {} - Err(error) => { - let _ = working_event_tx.send(Event::Error { - code: ErrorCode::Internal, - message: error.to_string(), - }); - } - } - } - Ok(_) => { - stage_pending_notification( - &pending_submissions, - ¬ify_buffer, - &source_namespace, - &request_id, - ); - } + Ok(_) => {} Err(error) => { let _ = working_event_tx.send(Event::Error { code: ErrorCode::InvalidRequest, @@ -1842,10 +2053,8 @@ async fn controller_loop( Method::NotifyTracked { notification_request_id, message, - auto_run, source, } => { - let request_id = notification_request_id.clone(); let (source_namespace, provenance) = resolved_input_source(&pending_submissions, &source); match pending_submissions.accept_notification_from_source( @@ -1853,28 +2062,8 @@ async fn controller_loop( message, source_namespace.clone(), provenance, - auto_run, ) { - Ok(_) if auto_run => { - match prepare_pending_run(&pending_submissions, ¬ify_buffer, None) { - Ok(Some(next)) => pending = Some(next), - Ok(None) => {} - Err(error) => { - let _ = working_event_tx.send(Event::Error { - code: ErrorCode::Internal, - message: error.to_string(), - }); - } - } - } - Ok(_) => { - stage_pending_notification( - &pending_submissions, - ¬ify_buffer, - &source_namespace, - &request_id, - ); - } + Ok(_) => {} Err(error) => { let _ = working_event_tx.send(Event::Error { code: ErrorCode::InvalidRequest, @@ -1935,6 +2124,7 @@ async fn controller_loop( &pending_submissions, ¬ify_buffer, Some((expected_revision, &expected_head_id)), + true, ) { Ok(Some(next)) => pending = Some(next), Ok(None) => { @@ -2174,7 +2364,15 @@ async fn controller_loop( ); let _ = cancel_tx.send(true); } - Some(method) => deferred_methods.push_back(method), + Some(method) => { + if let Some(method) = durably_accept_method_while_busy( + method, + &pending_submissions, + &working_event_tx, + ) { + deferred_methods.push_back(method); + } + } None => { shutdown_after_compaction = true; let _ = cancel_tx.send(true); @@ -2189,6 +2387,8 @@ async fn controller_loop( Err(WorkerError::Store(_)) | Err(WorkerError::WorkerStore(_)) | Err(WorkerError::InvalidState(_)) + | Err(WorkerError::SegmentActivationIncomplete { .. }) + | Err(WorkerError::CompactionCleanupPending { .. }) ) { set_controller_state( &shared_state, @@ -2205,6 +2405,16 @@ async fn controller_loop( }); } if shutdown_after_compaction { + while let Err(error) = worker + .finish_pending_compaction_cleanup_for_shutdown() + .await + { + let _ = working_event_tx.send(Event::Error { + code: worker_error_code(&error), + message: error.to_string(), + }); + tokio::time::sleep(Duration::from_millis(50)).await; + } let _ = working_event_tx.send(Event::Shutdown); break 'controller; } @@ -2361,7 +2571,6 @@ async fn controller_loop( if shared_state.catalog_status() == WorkerStatus::Idle { pending = Some(PendingRun::RunForNotification { invoke_kind: protocol::InvokeKind::WorkerEvent, - notification_request_id: None, }); } } @@ -2608,6 +2817,19 @@ where (WorkerStatus::Paused, shutdown_requested, false) } Err(e) => { + let status = if matches!( + &e, + WorkerError::SegmentActivationIncomplete { .. } + | WorkerError::CompactionCleanupPending { .. } + ) { + // Metadata has already committed the replacement, but + // one live authority failed to publish. Keep the + // controller visibly busy/fail-closed; publishing Idle + // would admit another turn against inconsistent state. + WorkerStatus::Running + } else { + WorkerStatus::Idle + }; let code = worker_error_code(&e); let message = e.to_string(); let _ = working_event_tx.send(Event::Error { @@ -2623,7 +2845,7 @@ where }, ); } - (WorkerStatus::Idle, shutdown_requested, false) + (status, shutdown_requested, false) } }; } @@ -2893,26 +3115,17 @@ where Some(Method::Notify { notification_request_id, message, - auto_run, }) => { - let request_id = notification_request_id.clone(); let source_namespace = pending_submissions.direct_client_namespace(); match pending_submissions.accept_notification_from_source( notification_request_id, message, source_namespace.clone(), session_store::LoggedSessionHistoryOrigin::LegacyUnknown, - auto_run, ) { - Ok(_) if !auto_run => { - stage_pending_notification( - &pending_submissions, - notify_buffer, - &source_namespace, - &request_id, - ); + Ok(_) => { + stage_pending_notifications(&pending_submissions, notify_buffer); } - Ok(_) => {} Err(error) => { let _ = working_event_tx.send(Event::Error { code: ErrorCode::InvalidRequest, @@ -2927,10 +3140,8 @@ where Some(Method::NotifyTracked { notification_request_id, message, - auto_run, source, }) => { - let request_id = notification_request_id.clone(); let (source_namespace, provenance) = resolved_input_source(pending_submissions, &source); match pending_submissions.accept_notification_from_source( @@ -2938,17 +3149,10 @@ where message, source_namespace.clone(), provenance, - auto_run, ) { - Ok(_) if !auto_run => { - stage_pending_notification( - &pending_submissions, - notify_buffer, - &source_namespace, - &request_id, - ); + Ok(_) => { + stage_pending_notifications(&pending_submissions, notify_buffer); } - Ok(_) => {} Err(error) => { let _ = working_event_tx.send(Event::Error { code: ErrorCode::InvalidRequest, @@ -3141,7 +3345,8 @@ mod tests { let temp = TempDir::new().unwrap(); let pending = crate::worker::PendingSubmissionHandle::for_test(&temp.path().join("sessions")); - let target = durable_parent_notification_target(pending.clone(), NotifyBuffer::new()); + let notify_buffer = NotifyBuffer::new(); + let target = durable_parent_notification_target(pending.clone(), notify_buffer.clone()); let (wire_namespace, wire_provenance) = resolved_input_source(&pending, &protocol::AuthenticatedInputSource::UntrustedWire); assert_eq!(wire_namespace, pending.direct_client_namespace()); @@ -3150,20 +3355,15 @@ mod tests { session_store::LoggedSessionHistoryOrigin::LegacyUnknown )); - target.notify("child-session".into(), "completed".into(), true); + target.notify("child-session".into(), "completed".into()); let snapshot = pending.snapshot(); - assert_eq!(snapshot.notification_count, 1); - assert!(snapshot.head_id.is_some()); - let notify_buffer = NotifyBuffer::new(); + assert_eq!(snapshot.notification_count, 0); assert!(matches!( - prepare_pending_run(&pending, ¬ify_buffer, None).unwrap(), - Some(PendingRun::RunForNotification { - notification_request_id: Some(_), - .. - }) + prepare_pending_run(&pending, ¬ify_buffer, None, true).unwrap(), + Some(PendingRun::RunForNotification { .. }) )); - assert!(notify_buffer.has_auto_run_pending()); + assert!(notify_buffer.has_notification_pending()); } #[test] @@ -3181,19 +3381,19 @@ mod tests { ) .unwrap(); pending - .accept_notification("notify-second".into(), "newer notification".into(), true) + .accept_notification("notify-second".into(), "newer notification".into()) .unwrap(); let notify_buffer = NotifyBuffer::new(); assert!(matches!( - prepare_pending_run(&pending, ¬ify_buffer, None).unwrap(), + prepare_pending_run(&pending, ¬ify_buffer, None, true).unwrap(), Some(PendingRun::Submit(_)) )); let pending = crate::worker::PendingSubmissionHandle::for_test(&temp.path().join("notify-first")); pending - .accept_notification("notify-first".into(), "older notification".into(), true) + .accept_notification("notify-first".into(), "older notification".into()) .unwrap(); pending .accept( @@ -3207,40 +3407,10 @@ mod tests { let notify_buffer = NotifyBuffer::new(); assert!(matches!( - prepare_pending_run(&pending, ¬ify_buffer, None).unwrap(), - Some(PendingRun::RunForNotification { - notification_request_id: Some(request_id), - .. - }) if request_id == "notify-first" - )); - - let pending = - crate::worker::PendingSubmissionHandle::for_test(&temp.path().join("passive-first")); - pending - .accept_notification("passive-first".into(), "passive notification".into(), false) - .unwrap(); - pending - .accept( - "submit-after-passive".into(), - vec![protocol::Segment::Text { - content: "queued after passive".into(), - }], - false, - ) - .unwrap(); - let snapshot = pending.snapshot(); - let head_id = snapshot.head_id.clone().expect("queued Submit is the head"); - let notify_buffer = NotifyBuffer::new(); - assert!(stage_oldest_passive_notification(&pending, ¬ify_buffer)); - assert!(matches!( - prepare_pending_run( - &pending, - ¬ify_buffer, - Some((snapshot.revision + 1, &head_id)), - ) - .unwrap(), + prepare_pending_run(&pending, ¬ify_buffer, None, true).unwrap(), Some(PendingRun::Submit(_)) )); + assert_eq!(notify_buffer.len(), 1); } #[test] @@ -3263,7 +3433,6 @@ mod tests { assert!( !PendingRun::RunForNotification { invoke_kind: protocol::InvokeKind::Notify, - notification_request_id: None, } .is_parent_originated() ); @@ -3414,6 +3583,137 @@ mod tests { } } + #[test] + fn busy_notification_is_durable_before_shutdown_can_observe_it() { + let dir = tempfile::tempdir().unwrap(); + let pending = PendingSubmissionHandle::for_test(dir.path()); + let (event_tx, _event_rx) = broadcast::channel(4); + + assert!( + durably_accept_method_while_busy( + Method::NotifyTracked { + notification_request_id: "completion-1".into(), + message: "subworker completed".into(), + source: protocol::AuthenticatedInputSource::UntrustedWire, + }, + &pending, + &event_tx, + ) + .is_none() + ); + let entries = pending.persisted_entries_for_test(); + assert!(entries.iter().any(|entry| matches!( + entry, + LogEntry::Extension { domain, .. } + if domain == "worker.pending_activations.v1" + ))); + } + + #[test] + fn attention_gate_rejects_submit_before_dispatch() { + let (event_tx, mut event_rx) = broadcast::channel(4); + let method = Method::Submit { + submission_request_id: "request-1".into(), + input: Vec::new(), + }; + + assert!(reject_method_while_attention_locked( + &method, + &event_tx, + "repair required", + )); + assert!(matches!( + event_rx.try_recv().unwrap(), + Event::SubmissionRejected { + submission_request_id, + message, + } if submission_request_id == "request-1" && message == "repair required" + )); + } + + #[tokio::test] + async fn segment_activation_failure_from_run_never_publishes_idle() { + let mut env = make_env().await; + let worker_future = async { + Err::(WorkerError::SegmentActivationIncomplete { + source: crate::runtime::worker_allocation::ScopeLockError::UnknownWorker( + "worker".into(), + ), + }) + }; + + let (status, shutdown, may_drain_pending) = drive_turn( + worker_future, + &mut env.method_rx, + &env.working_event_tx, + &env.cancel_tx, + &env.pause_tx, + &env.shared_state, + &env.runtime_dir, + None, + &env.notify_buffer, + &env.pending_submissions, + None, + "worker", + &env.spawned_registry, + false, + ) + .await; + + assert_eq!(status, WorkerStatus::Running); + assert!(!shutdown); + assert!(!may_drain_pending); + } + + #[tokio::test] + async fn pending_compaction_cleanup_preserves_shutdown_request_for_outer_barrier() { + let mut env = make_env().await; + let method_tx = env._method_tx.clone(); + env.shared_state + .transition(WorkerState::Busy(WorkerBusyState::Run( + WorkerRunState::Running, + ))); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(10)).await; + method_tx + .send(Method::Shutdown { + command: WorkerCommandEnvelope::new(1), + }) + .await + .expect("send shutdown"); + }); + let worker_future = async { + tokio::time::sleep(Duration::from_millis(50)).await; + Err::(WorkerError::CompactionCleanupPending { + source: crate::runtime::worker_allocation::ScopeLockError::UnknownWorker( + "worker".into(), + ), + }) + }; + + let (status, shutdown, may_drain_pending) = drive_turn( + worker_future, + &mut env.method_rx, + &env.working_event_tx, + &env.cancel_tx, + &env.pause_tx, + &env.shared_state, + &env.runtime_dir, + None, + &env.notify_buffer, + &env.pending_submissions, + None, + "worker", + &env.spawned_registry, + false, + ) + .await; + + assert_eq!(status, WorkerStatus::Running); + assert!(shutdown); + assert!(!may_drain_pending); + } + #[tokio::test] async fn pause_waits_for_run_boundary_and_uses_safe_pause_channel() { let mut env = make_env().await; @@ -3658,13 +3958,12 @@ mod tests { } #[tokio::test] - async fn running_auto_run_notify_remains_staged_for_followup_turn() { + async fn running_notify_remains_staged_for_the_next_model_boundary() { let mut env = make_env().await; env._method_tx .send(Method::Notify { notification_request_id: protocol::new_submission_request_id(), message: "continue".into(), - auto_run: true, }) .await .expect("send notify"); @@ -3693,8 +3992,8 @@ mod tests { assert_eq!(status, WorkerStatus::Idle); assert!(!shutdown); - assert_eq!(env.notify_buffer.len(), 0); - assert_eq!(env.pending_submissions.snapshot().notification_count, 1); + assert_eq!(env.notify_buffer.len(), 1); + assert_eq!(env.pending_submissions.snapshot().notification_count, 0); } #[tokio::test] diff --git a/crates/worker/src/discovery.rs b/crates/worker/src/discovery.rs index a3864b620..df0ec473e 100644 --- a/crates/worker/src/discovery.rs +++ b/crates/worker/src/discovery.rs @@ -389,7 +389,7 @@ where reason: detail.live.error, }; } - match send_notify(&detail.live.socket_path, message, false).await { + match send_notify(&detail.live.socket_path, message).await { Ok(()) => WeakNotifyDelivery::Delivered, Err(error) => WeakNotifyDelivery::SendFailed { error: error.to_string(), @@ -1009,17 +1009,16 @@ where } async fn send_peer_notify(socket_path: &Path, message: String) -> io::Result<()> { - send_notify(socket_path, message, true).await + send_notify(socket_path, message).await } -async fn send_notify(socket_path: &Path, message: String, auto_run: bool) -> io::Result<()> { +async fn send_notify(socket_path: &Path, message: String) -> io::Result<()> { let notification_request_id = protocol::new_submission_request_id(); connect_and_send( socket_path, &Method::NotifyTracked { notification_request_id: notification_request_id.clone(), message, - auto_run, source: protocol::AuthenticatedInputSource::Backend { operation_id: notification_request_id, }, @@ -1552,11 +1551,7 @@ mod tests { .await .unwrap(); let method = reader.next::().await.unwrap().unwrap(); - if let Method::NotifyTracked { - message, auto_run, .. - } = method - { - assert!(auto_run); + if let Method::NotifyTracked { message, .. } = method { tx.send(message).await.unwrap(); } else { panic!("expected Notify, got {method:?}"); @@ -1575,7 +1570,7 @@ mod tests { } #[tokio::test(flavor = "current_thread")] - async fn weak_notify_to_live_peer_uses_notify_without_auto_run_and_noops_when_missing() { + async fn weak_notify_to_live_peer_uses_realtime_notify_and_noops_when_missing() { let root = TempDir::new().unwrap(); let store_dir = root.path().join("store"); let runtime_base = root.path().join("runtime"); @@ -1674,11 +1669,7 @@ mod tests { .await .unwrap(); let method = reader.next::().await.unwrap().unwrap(); - if let Method::NotifyTracked { - message, auto_run, .. - } = method - { - assert!(!auto_run); + if let Method::NotifyTracked { message, .. } = method { tx.send(message).await.unwrap(); } else { panic!("expected Notify, got {method:?}"); diff --git a/crates/worker/src/feature/builtin/manage_workdir.rs b/crates/worker/src/feature/builtin/manage_workdir.rs index 33a93de8d..371718d9d 100644 --- a/crates/worker/src/feature/builtin/manage_workdir.rs +++ b/crates/worker/src/feature/builtin/manage_workdir.rs @@ -23,7 +23,7 @@ use workdir::{ WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult, }; -use workspace_api::{ +use server_api::{ WorkingDirectoryCreateRequest as WorkdirCreateRequest, WorkingDirectoryCreateResponse as WorkdirCreateResponse, WorkingDirectoryListResponse as WorkdirListResponse, diff --git a/crates/worker/src/feature/builtin/workspace_worker_discovery.rs b/crates/worker/src/feature/builtin/workspace_worker_discovery.rs index 4d8ea2685..5fcfc0e51 100644 --- a/crates/worker/src/feature/builtin/workspace_worker_discovery.rs +++ b/crates/worker/src/feature/builtin/workspace_worker_discovery.rs @@ -168,7 +168,7 @@ fn input_schema() -> serde_json::Value { mod tests { use std::sync::Mutex; - use workspace_api::{ + use server_api::{ WorkspaceWorkerDiscoveryItem, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject, }; diff --git a/crates/worker/src/ipc/interceptor.rs b/crates/worker/src/ipc/interceptor.rs index 01498e4d5..773c2298a 100644 --- a/crates/worker/src/ipc/interceptor.rs +++ b/crates/worker/src/ipc/interceptor.rs @@ -1543,7 +1543,7 @@ mod tests { PromptCatalog::from_projection(projection).unwrap(), )); - buffer.push_notify("updated".to_string(), false); + buffer.push_notify("updated".to_string()); let appends = interceptor .pending_history_appends(PendingHistoryAppendsContext { invocation: Default::default(), @@ -1598,7 +1598,7 @@ mod tests { prompts.store(Arc::new( PromptCatalog::from_projection(projection).unwrap(), )); - buffer.push_notify("must persist".to_string(), false); + buffer.push_notify("must persist".to_string()); let error = interceptor .pending_history_appends(PendingHistoryAppendsContext { @@ -1621,8 +1621,8 @@ mod tests { async fn pending_history_appends_drains_buffer_into_items() { let registry = Arc::new(HookRegistryBuilder::new().build()); let buffer = NotifyBuffer::new(); - buffer.push_notify("first".into(), false); - buffer.push_notify("second".into(), false); + buffer.push_notify("first".into()); + buffer.push_notify("second".into()); let interceptor = WorkerInterceptor::new( registry, @@ -1671,7 +1671,7 @@ mod tests { // anything itself. let registry = Arc::new(HookRegistryBuilder::new().build()); let buffer = NotifyBuffer::new(); - buffer.push_notify("msg".into(), false); + buffer.push_notify("msg".into()); let interceptor = WorkerInterceptor::new( registry, diff --git a/crates/worker/src/ipc/notify_buffer.rs b/crates/worker/src/ipc/notify_buffer.rs index 2fd2ca030..699a778cf 100644 --- a/crates/worker/src/ipc/notify_buffer.rs +++ b/crates/worker/src/ipc/notify_buffer.rs @@ -26,13 +26,9 @@ use std::sync::{Arc, Mutex}; use protocol::WorkerEvent; use session_store::{LoggedSessionHistoryOrigin, SessionExtension, SystemItem}; -use tracing::warn; use crate::prompt::catalog::{CatalogError, PromptCatalog}; -/// Maximum queued pending entries. Oldest entries are dropped beyond this. -const CAPACITY: usize = 128; - /// One pending entry awaiting drain into the next LLM request. /// /// The buffer keeps the raw input shape so the drain step can decide @@ -43,7 +39,6 @@ const CAPACITY: usize = 128; pub enum PendingNotify { Notify { message: String, - auto_run: bool, extensions: Vec, history_provenance: Option, }, @@ -76,6 +71,7 @@ impl PendingNotify { #[derive(Clone, Default)] pub struct NotifyBuffer { inner: Arc>>, + wake: Arc, } impl NotifyBuffer { @@ -86,10 +82,9 @@ impl NotifyBuffer { /// Push a notify entry onto the queue. If the queue is full, the /// oldest entry is dropped and a `tracing::warn` is emitted — the /// caller should never hit this in normal operation. - pub fn push_notify(&self, message: String, auto_run: bool) { + pub fn push_notify(&self, message: String) { self.push_entry(PendingNotify::Notify { message, - auto_run, extensions: Vec::new(), history_provenance: None, }); @@ -98,53 +93,32 @@ impl NotifyBuffer { pub fn push_durable_notify( &self, message: String, - auto_run: bool, history_provenance: LoggedSessionHistoryOrigin, extension: SessionExtension, ) { self.push_entry(PendingNotify::Notify { message, - auto_run, extensions: vec![extension], history_provenance: Some(history_provenance), }); } - pub(crate) fn replace_durable_notification_extension( - &self, - extension: SessionExtension, - ) -> bool { - let mut queue = self.inner.lock().expect("notify buffer poisoned"); - let Some(extensions) = queue.iter_mut().rev().find_map(|pending| match pending { - PendingNotify::Notify { - auto_run: false, - extensions, - .. - } if !extensions.is_empty() => Some(extensions), - _ => None, - }) else { - return false; - }; - *extensions = vec![extension]; - true - } - /// Push a typed worker-event entry onto the queue. pub fn push_worker_event(&self, event: WorkerEvent) { self.push_entry(PendingNotify::WorkerEvent { event }); } fn push_entry(&self, entry: PendingNotify) { - let mut q = self.inner.lock().expect("notify buffer poisoned"); - if q.len() >= CAPACITY { - let dropped = q.pop_front(); - warn!( - capacity = CAPACITY, - dropped = ?dropped, - "notify buffer overflow; dropped oldest" - ); - } - q.push_back(entry); + self.inner + .lock() + .expect("notify buffer poisoned") + .push_back(entry); + self.wake.notify_one(); + } + + /// Wait until a producer adds work outside the controller method channel. + pub async fn notified(&self) { + self.wake.notified().await; } /// Remove and return all pending entries in FIFO order. @@ -160,23 +134,17 @@ impl NotifyBuffer { for entry in entries.into_iter().rev() { q.push_front(entry); } - while q.len() > CAPACITY { - let dropped = q.pop_front(); - warn!( - capacity = CAPACITY, - dropped = ?dropped, - "notify buffer overflow while restoring failed drain; dropped oldest" - ); - } + drop(q); + self.wake.notify_one(); } - /// Whether an undrained `Method::Notify { auto_run: true }` remains. - pub fn has_auto_run_pending(&self) -> bool { + /// Whether an undrained notification remains. + pub fn has_notification_pending(&self) -> bool { self.inner .lock() .expect("notify buffer poisoned") .iter() - .any(|entry| matches!(entry, PendingNotify::Notify { auto_run: true, .. })) + .any(|entry| matches!(entry, PendingNotify::Notify { .. })) } /// Number of pending entries. Primarily for tests. @@ -233,12 +201,12 @@ mod tests { #[test] fn push_then_drain_preserves_order() { let buf = NotifyBuffer::new(); - buf.push_notify("one".into(), false); - assert!(!buf.has_auto_run_pending()); - buf.push_notify("two".into(), true); - assert!(buf.has_auto_run_pending()); + buf.push_notify("one".into()); + assert!(buf.has_notification_pending()); + buf.push_notify("two".into()); + assert!(buf.has_notification_pending()); let drained = buf.drain(); - assert!(!buf.has_auto_run_pending()); + assert!(!buf.has_notification_pending()); assert_eq!(drained.len(), 2); match &drained[0] { PendingNotify::Notify { message, .. } => assert_eq!(message, "one"), @@ -248,15 +216,15 @@ mod tests { } #[test] - fn capacity_drops_oldest() { + fn notification_burst_is_not_truncated() { let buf = NotifyBuffer::new(); - for i in 0..(CAPACITY + 5) { - buf.push_notify(format!("msg{i}"), false); + for i in 0..300 { + buf.push_notify(format!("msg{i}")); } let drained = buf.drain(); - assert_eq!(drained.len(), CAPACITY); + assert_eq!(drained.len(), 300); match &drained[0] { - PendingNotify::Notify { message, .. } => assert_eq!(message, "msg5"), + PendingNotify::Notify { message, .. } => assert_eq!(message, "msg0"), other => panic!("unexpected: {other:?}"), } } @@ -265,7 +233,6 @@ mod tests { fn build_system_item_for_notify_carries_wrapper_body() { let entry = PendingNotify::Notify { message: "hello".into(), - auto_run: false, extensions: Vec::new(), history_provenance: None, }; diff --git a/crates/worker/src/runtime/worker_allocation.rs b/crates/worker/src/runtime/worker_allocation.rs index 1eb3874bb..c7445cdaa 100644 --- a/crates/worker/src/runtime/worker_allocation.rs +++ b/crates/worker/src/runtime/worker_allocation.rs @@ -18,12 +18,16 @@ pub use conflict::{ ConflictOwner, find_conflict_owner, find_conflict_owners, is_within_effective_write, }; pub use error::ScopeLockError; +#[cfg(test)] +pub(crate) use lifecycle::install_top_level_at_for_test; pub use lifecycle::{ - ScopeAllocationGuard, SegmentLockInfo, adopt_allocation, install_top_level, - install_top_level_with_deny, lookup_segment, update_segment, + ScopeAllocationGuard, SegmentActivationCommitError, SegmentActivationGuard, SegmentLockInfo, + adopt_allocation, install_top_level, install_top_level_with_deny, lookup_segment, }; pub use mutate::{ delegate_scope, reclaim_delegated_scope, reclaim_stale, reclaim_stale_with, register_worker, register_worker_with_deny, release_worker, }; +#[cfg(test)] +pub(crate) use table::fail_next_save_for_test; pub use table::{Allocation, LockFile, LockFileGuard, default_allocation_path}; diff --git a/crates/worker/src/runtime/worker_allocation/error.rs b/crates/worker/src/runtime/worker_allocation/error.rs index a0bcb78ef..601202693 100644 --- a/crates/worker/src/runtime/worker_allocation/error.rs +++ b/crates/worker/src/runtime/worker_allocation/error.rs @@ -37,4 +37,12 @@ pub enum ScopeLockError { worker_name: String, socket: PathBuf, }, + #[error( + "worker `{worker_name}` allocation moved from expected Segment {expected} to {actual:?}" + )] + SegmentChanged { + worker_name: String, + expected: SegmentId, + actual: Option, + }, } diff --git a/crates/worker/src/runtime/worker_allocation/lifecycle.rs b/crates/worker/src/runtime/worker_allocation/lifecycle.rs index 480d895af..a596c1aa9 100644 --- a/crates/worker/src/runtime/worker_allocation/lifecycle.rs +++ b/crates/worker/src/runtime/worker_allocation/lifecycle.rs @@ -20,6 +20,41 @@ pub struct ScopeAllocationGuard { lock_path: PathBuf, } +/// Exclusive authority to activate a replacement Segment for one allocated Worker. +/// +/// The allocation lock remains held across the durable metadata CAS and the final +/// allocation-table update, so restore admission cannot observe an intermediate +/// ownership state. +pub struct SegmentActivationGuard { + guard: LockFileGuard, + worker_name: String, + expected: SegmentId, + replacement: SegmentId, +} + +/// A failed allocation-table publication together with the still-held machine-wide +/// activation authority. Callers must retain `authority` until recovery or process +/// teardown so restore admission cannot register a competing writer. +pub struct SegmentActivationCommitError { + source: ScopeLockError, + authority: SegmentActivationGuard, +} + +impl std::fmt::Debug for SegmentActivationCommitError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("SegmentActivationCommitError") + .field("source", &self.source) + .finish_non_exhaustive() + } +} + +impl SegmentActivationCommitError { + pub fn into_parts(self) -> (ScopeLockError, SegmentActivationGuard) { + (self.source, self.authority) + } +} + impl ScopeAllocationGuard { pub fn worker_name(&self) -> &str { &self.worker_name @@ -28,6 +63,86 @@ impl ScopeAllocationGuard { pub fn lock_path(&self) -> &Path { &self.lock_path } + + /// Lock the machine-wide table and validate a Segment replacement before + /// its durable metadata is changed. + pub fn begin_segment_activation( + &self, + expected: SegmentId, + replacement: SegmentId, + ) -> Result { + let guard = LockFileGuard::open(&self.lock_path)?; + let actual = guard + .data() + .allocations + .iter() + .find(|allocation| allocation.worker_name == self.worker_name) + .and_then(|allocation| allocation.segment_id); + + if actual != Some(expected) { + return Err(ScopeLockError::SegmentChanged { + worker_name: self.worker_name.clone(), + expected, + actual, + }); + } + + if let Some(existing) = guard.data().allocations.iter().find(|allocation| { + allocation.segment_id == Some(replacement) && allocation.worker_name != self.worker_name + }) { + return Err(ScopeLockError::SegmentConflict { + segment_id: replacement, + worker_name: existing.worker_name.clone(), + socket: existing.socket.clone(), + }); + } + + Ok(SegmentActivationGuard { + guard, + worker_name: self.worker_name.clone(), + expected, + replacement, + }) + } +} + +impl SegmentActivationGuard { + /// Persist the replacement Segment while retaining exclusive allocation + /// authority. This is intentionally consumed so a successful metadata CAS + /// cannot accidentally be followed by an unlocked update. + pub fn commit(mut self) -> Result<(), SegmentActivationCommitError> { + let actual = self + .guard + .data() + .allocations + .iter() + .find(|allocation| allocation.worker_name == self.worker_name) + .and_then(|allocation| allocation.segment_id); + let Some(allocation) = self + .guard + .data_mut() + .allocations + .iter_mut() + .find(|allocation| allocation.worker_name == self.worker_name) + else { + return Err(SegmentActivationCommitError { + source: ScopeLockError::SegmentChanged { + worker_name: self.worker_name.clone(), + expected: self.expected, + actual, + }, + authority: self, + }); + }; + allocation.segment_id = Some(self.replacement); + if let Err(source) = self.guard.save() { + return Err(SegmentActivationCommitError { + source: ScopeLockError::Io(source), + authority: self, + }); + } + Ok(()) + } } impl Drop for ScopeAllocationGuard { @@ -85,6 +200,31 @@ pub fn install_top_level_with_deny( }) } +#[cfg(test)] +pub(crate) fn install_top_level_at_for_test( + lock_path: PathBuf, + worker_name: String, + pid: u32, + socket: PathBuf, + scope_allow: Vec, + segment_id: SegmentId, +) -> Result { + let mut guard = LockFileGuard::open(&lock_path)?; + super::mutate::register_worker_with_deny( + &mut guard, + worker_name.clone(), + pid, + socket, + scope_allow, + Vec::new(), + segment_id, + )?; + Ok(ScopeAllocationGuard { + worker_name, + lock_path, + }) +} + /// Take ownership of an existing allocation that was pre-registered by /// a spawning Worker. /// @@ -114,46 +254,6 @@ pub fn adopt_allocation( }) } -/// Rewrite the `segment_id` recorded for `worker_name` to -/// `new_segment_id`. -/// -/// The Worker's in-memory `segment_id` can change underneath the -/// allocation in two normal places: -/// -/// - `Worker::compact` mints a fresh Segment in the same Session. -/// - `session_store::ensure_head_or_fork` auto-forks within that Session when another -/// writer has advanced the store head behind our back. -/// -/// Both paths must call this so subsequent [`lookup_segment`] queries -/// find the live Segment id, not the old one. Without this update a -/// concurrent `restore_from_manifest(new_id)` would see "no live -/// writer" and proceed to register a competing allocation on the -/// Segment lineage this Worker just moved into. -/// -/// The lock is opened once and the allocation is rewritten inside the -/// guard, so the segment_id collision check is atomic with the -/// rewrite. -pub fn update_segment(worker_name: &str, new_segment_id: SegmentId) -> Result<(), ScopeLockError> { - let lock_path = default_allocation_path()?; - let mut guard = LockFileGuard::open(&lock_path)?; - if let Some(other) = guard.data().find_by_segment(new_segment_id) { - if other.worker_name != worker_name { - return Err(ScopeLockError::SegmentConflict { - segment_id: new_segment_id, - worker_name: other.worker_name.clone(), - socket: other.socket.clone(), - }); - } - } - let alloc = guard - .data_mut() - .find_mut(worker_name) - .ok_or_else(|| ScopeLockError::UnknownWorker(worker_name.into()))?; - alloc.segment_id = Some(new_segment_id); - guard.save()?; - Ok(()) -} - /// Information about a Worker that currently holds an allocation for a /// given session. #[derive(Debug, Clone)] @@ -184,7 +284,7 @@ pub fn lookup_segment(segment_id: SegmentId) -> Result, #[cfg(test)] mod tests { - use super::super::table::Allocation; + use super::super::table::{Allocation, LockFile}; use super::super::test_util::*; use super::*; use tempfile::TempDir; @@ -283,32 +383,12 @@ mod tests { } #[test] - fn update_session_rewrites_allocation_session_id() { - let dir = TempDir::new().unwrap(); - let _sandbox = RuntimeDirSandbox::new(dir.path()); - let original = sid(); - let updated = sid(); - let _guard = install_top_level( - "p".into(), - std::process::id(), - sock("p"), - vec![write_rule("/work", true)], - original, - ) - .unwrap(); - update_segment("p", updated).unwrap(); - // lookup against the original is now empty, the updated id wins. - assert!(lookup_segment(original).unwrap().is_none()); - assert_eq!(lookup_segment(updated).unwrap().unwrap().worker_name, "p"); - } - - #[test] - fn update_session_rejects_when_target_already_held() { + fn segment_activation_rejects_when_target_already_held() { let dir = TempDir::new().unwrap(); let _sandbox = RuntimeDirSandbox::new(dir.path()); let s_a = sid(); let s_b = sid(); - let _g_a = install_top_level( + let g_a = install_top_level( "a".into(), std::process::id(), sock("a"), @@ -324,8 +404,11 @@ mod tests { s_b, ) .unwrap(); - // `a` cannot adopt b's live session id. - let err = update_segment("a", s_b).unwrap_err(); + // `a` cannot activate b's live Segment id. + let err = match g_a.begin_segment_activation(s_a, s_b) { + Ok(_) => panic!("expected SegmentConflict"), + Err(error) => error, + }; match err { ScopeLockError::SegmentConflict { worker_name, @@ -338,4 +421,191 @@ mod tests { other => panic!("expected SegmentConflict, got {other:?}"), } } + + #[test] + fn segment_activation_commits_verified_replacement() { + let dir = TempDir::new().unwrap(); + let _sandbox = RuntimeDirSandbox::new(dir.path()); + let lock_path = dir.path().join("workers.json"); + let old_segment = sid(); + let replacement = sid(); + let guard = install_top_level( + "activation".into(), + std::process::id(), + sock("activation"), + vec![write_rule("/work", true)], + old_segment, + ) + .unwrap(); + + guard + .begin_segment_activation(old_segment, replacement) + .unwrap() + .commit() + .unwrap(); + + let table = LockFileGuard::open(&lock_path).unwrap(); + assert_eq!( + table + .data() + .find("activation") + .and_then(|allocation| allocation.segment_id), + Some(replacement) + ); + } + + #[test] + fn failed_segment_activation_returns_locked_recovery_authority() { + let dir = TempDir::new().unwrap(); + let _sandbox = RuntimeDirSandbox::new(dir.path()); + let old_segment = sid(); + let replacement = sid(); + let guard = install_top_level( + "activation".into(), + std::process::id(), + sock("activation"), + vec![write_rule("/work", true)], + old_segment, + ) + .unwrap(); + let mut activation = guard + .begin_segment_activation(old_segment, replacement) + .unwrap(); + activation + .guard + .data_mut() + .allocations + .retain(|allocation| allocation.worker_name != "activation"); + + let error = activation.commit().unwrap_err(); + let (source, authority) = error.into_parts(); + assert!(matches!( + source, + ScopeLockError::SegmentChanged { + expected, + actual: None, + .. + } if expected == old_segment + )); + // The authority is returned rather than dropped on failure. Production + // stores it on Worker to keep restore admission blocked. + let (sent, received) = std::sync::mpsc::channel(); + let lookup = std::thread::spawn(move || { + sent.send(lookup_segment(replacement).unwrap()).unwrap(); + }); + assert!( + received + .recv_timeout(std::time::Duration::from_millis(50)) + .is_err() + ); + drop(authority); + assert!( + received + .recv_timeout(std::time::Duration::from_secs(1)) + .unwrap() + .is_none() + ); + lookup.join().unwrap(); + } + + #[test] + fn allocation_save_failure_preserves_source_table_and_returns_authority() { + let dir = TempDir::new().unwrap(); + let _sandbox = RuntimeDirSandbox::new(dir.path()); + let lock_path = dir.path().join("workers.json"); + let old_segment = sid(); + let replacement = sid(); + let guard = install_top_level( + "activation".into(), + std::process::id(), + sock("activation"), + vec![write_rule("/work", true)], + old_segment, + ) + .unwrap(); + let activation = guard + .begin_segment_activation(old_segment, replacement) + .unwrap(); + crate::runtime::worker_allocation::table::fail_next_save_for_test(&lock_path); + + let error = activation.commit().unwrap_err(); + let (source, authority) = error.into_parts(); + assert!(matches!(source, ScopeLockError::Io(_))); + let persisted: LockFile = + serde_json::from_slice(&std::fs::read(&lock_path).unwrap()).unwrap(); + assert_eq!( + persisted + .find("activation") + .and_then(|allocation| allocation.segment_id), + Some(old_segment) + ); + drop(authority); + } + + #[test] + fn dropping_segment_activation_preserves_source_allocation() { + let dir = TempDir::new().unwrap(); + let _sandbox = RuntimeDirSandbox::new(dir.path()); + let lock_path = dir.path().join("workers.json"); + let old_segment = sid(); + let replacement = sid(); + let guard = install_top_level( + "activation".into(), + std::process::id(), + sock("activation"), + vec![write_rule("/work", true)], + old_segment, + ) + .unwrap(); + + drop( + guard + .begin_segment_activation(old_segment, replacement) + .unwrap(), + ); + + let table = LockFileGuard::open(&lock_path).unwrap(); + assert_eq!( + table + .data() + .find("activation") + .and_then(|allocation| allocation.segment_id), + Some(old_segment) + ); + } + + #[test] + fn segment_activation_rejects_stale_expected_segment_without_mutation() { + let dir = TempDir::new().unwrap(); + let _sandbox = RuntimeDirSandbox::new(dir.path()); + let lock_path = dir.path().join("workers.json"); + let allocated = sid(); + let stale = sid(); + let replacement = sid(); + let guard = install_top_level( + "activation".into(), + std::process::id(), + sock("activation"), + vec![write_rule("/work", true)], + allocated, + ) + .unwrap(); + + assert!(matches!( + guard.begin_segment_activation(stale, replacement), + Err(ScopeLockError::SegmentChanged { + expected, + actual: Some(actual), + .. + }) if expected == stale && actual == allocated + )); + let table = LockFileGuard::open(&lock_path).unwrap(); + assert_eq!( + table + .data() + .find("activation") + .and_then(|allocation| allocation.segment_id), + Some(allocated) + ); + } } diff --git a/crates/worker/src/runtime/worker_allocation/table.rs b/crates/worker/src/runtime/worker_allocation/table.rs index 3056074f1..af7c6159a 100644 --- a/crates/worker/src/runtime/worker_allocation/table.rs +++ b/crates/worker/src/runtime/worker_allocation/table.rs @@ -1,12 +1,48 @@ //! On-disk allocation table and the `flock`-protected guard. -use std::fs::{DirBuilder, File, OpenOptions}; +use std::fs::{self, DirBuilder, File, OpenOptions}; use std::io::{self, Read, Seek, SeekFrom, Write}; use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt}; use std::path::{Path, PathBuf}; use std::thread; use std::time::{Duration, Instant}; +#[cfg(test)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SaveFaultPoint { + BeforeRename, + AfterRename, +} + +#[cfg(test)] +static SAVE_FAULTS: std::sync::LazyLock< + std::sync::Mutex>, +> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new())); + +#[cfg(test)] +pub(crate) fn fail_next_save_for_test(path: &Path) { + fail_next_save_at_for_test(path, SaveFaultPoint::BeforeRename); +} + +#[cfg(test)] +pub(crate) fn fail_next_save_at_for_test(path: &Path, point: SaveFaultPoint) { + SAVE_FAULTS + .lock() + .unwrap() + .insert(path.to_path_buf(), point); +} + +#[cfg(test)] +fn take_save_fault(path: &Path, point: SaveFaultPoint) -> bool { + let mut faults = SAVE_FAULTS.lock().unwrap(); + if faults.get(path) == Some(&point) { + faults.remove(path); + true + } else { + false + } +} + use fs4::fs_std::FileExt; use manifest::{ScopeRule, paths}; use serde::{Deserialize, Serialize}; @@ -101,7 +137,9 @@ pub fn default_allocation_path() -> io::Result { /// never call `save` leave the table unchanged, which is the right /// behaviour for error paths. pub struct LockFileGuard { - file: File, + data_file: File, + lock_file: File, + data_path: PathBuf, data: LockFile, } @@ -121,16 +159,19 @@ impl LockFileGuard { .mode(0o700) .create(parent)?; } - let file = OpenOptions::new() + let mut lock_os = path.as_os_str().to_owned(); + lock_os.push(".lock"); + let lock_path = PathBuf::from(lock_os); + let lock_file = OpenOptions::new() .read(true) .write(true) .create(true) .truncate(false) .mode(0o600) - .open(path)?; + .open(&lock_path)?; let started = Instant::now(); loop { - match FileExt::try_lock_exclusive(&file) { + match FileExt::try_lock_exclusive(&lock_file) { Ok(true) => break, Ok(false) => { if started.elapsed() >= LOCK_WAIT_TIMEOUT { @@ -159,8 +200,17 @@ impl LockFileGuard { Err(error) => return Err(error), } } + let data_file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(path)?; let mut this = Self { - file, + data_file, + lock_file, + data_path: path.to_path_buf(), data: LockFile::default(), }; this.reload()?; @@ -168,9 +218,9 @@ impl LockFileGuard { } fn reload(&mut self) -> io::Result<()> { - self.file.seek(SeekFrom::Start(0))?; + self.data_file.seek(SeekFrom::Start(0))?; let mut buf = String::new(); - self.file.read_to_string(&mut buf)?; + self.data_file.read_to_string(&mut buf)?; self.data = if buf.trim().is_empty() { LockFile::default() } else { @@ -192,20 +242,49 @@ impl LockFileGuard { &mut self.data } - /// Serialise `self.data` back to the file (truncate + rewrite). + /// Persist with atomic replacement while the separate allocation lock stays + /// held. A crash exposes either the old complete table or the new one. pub fn save(&mut self) -> io::Result<()> { let json = serde_json::to_vec_pretty(&self.data).map_err(io::Error::other)?; - self.file.seek(SeekFrom::Start(0))?; - self.file.set_len(0)?; - self.file.write_all(&json)?; - self.file.sync_data()?; + let mut temp_os = self.data_path.as_os_str().to_owned(); + temp_os.push(".tmp"); + let temp_path = PathBuf::from(temp_os); + let mut temp_file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&temp_path)?; + temp_file.write_all(&json)?; + temp_file.sync_all()?; + #[cfg(test)] + if take_save_fault(&self.data_path, SaveFaultPoint::BeforeRename) { + return Err(io::Error::other( + "injected allocation save failure before rename", + )); + } + fs::rename(&temp_path, &self.data_path)?; + #[cfg(test)] + if take_save_fault(&self.data_path, SaveFaultPoint::AfterRename) { + return Err(io::Error::other( + "injected allocation save failure after rename", + )); + } + if let Some(parent) = self.data_path.parent() { + File::open(parent)?.sync_all()?; + } + self.data_file = OpenOptions::new() + .read(true) + .write(true) + .mode(0o600) + .open(&self.data_path)?; Ok(()) } } impl Drop for LockFileGuard { fn drop(&mut self) { - let _ = FileExt::unlock(&self.file); + let _ = FileExt::unlock(&self.lock_file); } } @@ -259,6 +338,76 @@ mod tests { assert_eq!(guard.data().allocations[0].worker_name, "a"); } + #[test] + fn atomic_save_reopens_complete_table_at_both_rename_boundaries() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("workers.json"); + let old_segment = sid(); + let replacement = sid(); + { + let mut guard = open_empty(&path); + register_worker( + &mut guard, + "a".into(), + std::process::id(), + sock("a"), + vec![write_rule("/src", true)], + old_segment, + ) + .unwrap(); + } + + { + let mut guard = LockFileGuard::open(&path).unwrap(); + guard.data_mut().find_mut("a").unwrap().segment_id = Some(replacement); + fail_next_save_at_for_test(&path, SaveFaultPoint::BeforeRename); + assert!(guard.save().is_err()); + } + let guard = LockFileGuard::open(&path).unwrap(); + assert_eq!( + guard + .data() + .find("a") + .and_then(|allocation| allocation.segment_id), + Some(old_segment) + ); + drop(guard); + + { + let mut guard = LockFileGuard::open(&path).unwrap(); + guard.data_mut().find_mut("a").unwrap().segment_id = Some(replacement); + fail_next_save_at_for_test(&path, SaveFaultPoint::AfterRename); + assert!(guard.save().is_err()); + } + let guard = LockFileGuard::open(&path).unwrap(); + assert_eq!( + guard + .data() + .find("a") + .and_then(|allocation| allocation.segment_id), + Some(replacement) + ); + } + + #[test] + fn allocation_lock_remains_exclusive_across_atomic_save() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("workers.json"); + let mut guard = open_empty(&path); + guard.save().unwrap(); + + let (sent, received) = std::sync::mpsc::channel(); + let contender_path = path.clone(); + let contender = std::thread::spawn(move || { + let contender = LockFileGuard::open(&contender_path).unwrap(); + sent.send(contender.data().allocations.len()).unwrap(); + }); + assert!(received.recv_timeout(Duration::from_millis(50)).is_err()); + drop(guard); + assert_eq!(received.recv_timeout(Duration::from_secs(1)).unwrap(), 0); + contender.join().unwrap(); + } + #[test] fn find_by_session_skips_none_placeholders() { let dir = TempDir::new().unwrap(); diff --git a/crates/worker/src/skill.rs b/crates/worker/src/skill.rs index ecb47ced3..9eca47898 100644 --- a/crates/worker/src/skill.rs +++ b/crates/worker/src/skill.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use crate::worker::WorkspaceClient; -pub use workspace_api::{ +pub use server_api::{ SkillActivationStatus, SkillCatalogEntry, SkillCatalogResponse, SkillDetailResponse, SkillDiagnostic, SkillDiagnosticSeverity, SkillProjectionIdentity, SkillProjectionStatus, SkillProvenance, SkillResourceRef, SkillSourceKind, @@ -29,7 +29,7 @@ pub enum SkillClientError { #[error("Skill API response JSON is invalid: {0}")] Json(#[from] serde_json::Error), #[error("Skill API response violates the shared contract: {0}")] - InvalidResponse(#[from] workspace_api::SkillApiValidationError), + InvalidResponse(#[from] server_api::SkillApiValidationError), #[error("Skill API returned HTTP {status}: {body}")] Http { status: reqwest::StatusCode, diff --git a/crates/worker/src/spawn/registry.rs b/crates/worker/src/spawn/registry.rs index f328838d0..7c2606f74 100644 --- a/crates/worker/src/spawn/registry.rs +++ b/crates/worker/src/spawn/registry.rs @@ -8,6 +8,8 @@ //! Parent registry drop closes all session handles and synchronously returns delegated Write deny //! rules to the parent scope. +#[cfg(test)] +use std::collections::HashMap; use std::collections::{BTreeMap, HashSet}; use std::io; use std::sync::{ @@ -311,6 +313,8 @@ impl Drop for InternalSpawnReservation { pub struct SpawnedWorkerRegistry { internal_records: std::sync::Mutex>, service_records: std::sync::Mutex>, + #[cfg(test)] + fail_service_stops: std::sync::Mutex>, internal_names: std::sync::Mutex>, internal_shutting_down: AtomicBool, pending_internal_spawns: AtomicUsize, @@ -331,6 +335,8 @@ impl SpawnedWorkerRegistry { Arc::new(Self { internal_records: std::sync::Mutex::new(Vec::new()), service_records: std::sync::Mutex::new(Vec::new()), + #[cfg(test)] + fail_service_stops: std::sync::Mutex::new(HashMap::new()), internal_names: std::sync::Mutex::new(HashSet::new()), internal_shutting_down: AtomicBool::new(false), pending_internal_spawns: AtomicUsize::new(0), @@ -346,6 +352,8 @@ impl SpawnedWorkerRegistry { Arc::new(Self { internal_records: std::sync::Mutex::new(Vec::new()), service_records: std::sync::Mutex::new(Vec::new()), + #[cfg(test)] + fail_service_stops: std::sync::Mutex::new(HashMap::new()), internal_names: std::sync::Mutex::new(HashSet::new()), internal_shutting_down: AtomicBool::new(false), pending_internal_spawns: AtomicUsize::new(0), @@ -360,6 +368,8 @@ impl SpawnedWorkerRegistry { Arc::new(Self { internal_records: std::sync::Mutex::new(Vec::new()), service_records: std::sync::Mutex::new(Vec::new()), + #[cfg(test)] + fail_service_stops: std::sync::Mutex::new(HashMap::new()), internal_names: std::sync::Mutex::new(HashSet::new()), internal_shutting_down: AtomicBool::new(false), pending_internal_spawns: AtomicUsize::new(0), @@ -443,6 +453,8 @@ impl SpawnedWorkerRegistry { registry: Arc::new(Self { internal_records: std::sync::Mutex::new(Vec::new()), service_records: std::sync::Mutex::new(Vec::new()), + #[cfg(test)] + fail_service_stops: std::sync::Mutex::new(HashMap::new()), internal_names: std::sync::Mutex::new(HashSet::new()), internal_shutting_down: AtomicBool::new(false), pending_internal_spawns: AtomicUsize::new(0), @@ -535,9 +547,53 @@ impl SpawnedWorkerRegistry { Ok(worker_ref) } - /// Stop and remove one parent-owned service Worker. This is host-only and is - /// intentionally separate from the SubWorker control surface. + #[cfg(test)] + pub(crate) fn install_service_for_test( + &self, + ) -> (String, tokio::sync::broadcast::Sender) { + let (session, status_tx) = crate::internal_worker::test_internal_worker_session( + crate::internal_worker::InternalWorkerVisibility::ServicePrivate, + ); + let session_id = session.session_id_string(); + self.service_records + .lock() + .unwrap() + .push(InternalServiceWorkerRecord::new( + "test-service", + "test-service", + session, + )); + (session_id, status_tx) + } + + #[cfg(test)] + pub(crate) fn has_service_for_test(&self, session_id: &str) -> bool { + self.service_records + .lock() + .unwrap() + .iter() + .any(|record| record.session.session_id_string() == session_id) + } + + #[cfg(test)] + pub(crate) fn fail_service_stops_for_test(&self, session_id: &str, count: usize) { + self.fail_service_stops + .lock() + .unwrap() + .insert(session_id.to_owned(), count); + } + pub(crate) async fn stop_service(&self, session_id: &str) -> io::Result { + #[cfg(test)] + { + let mut failures = self.fail_service_stops.lock().unwrap(); + if let Some(remaining) = failures.get_mut(session_id) { + if *remaining > 0 { + *remaining -= 1; + return Err(io::Error::other("injected service stop failure")); + } + } + } let record = self .service_records .lock() diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index 5622dfbcf..60efb8c68 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -262,28 +262,28 @@ impl ParentNotificationTarget { Self::Controller { sender, fallback } } - pub(crate) fn notify(&self, child_session_id: String, message: String, auto_run: bool) { + pub(crate) fn notify(&self, child_session_id: String, message: String) { let method = Method::NotifyTracked { notification_request_id: protocol::new_submission_request_id(), message, - auto_run, source: protocol::AuthenticatedInputSource::SubWorker { session_id: child_session_id, }, }; match self { Self::Controller { sender, fallback } => { + // Persist through the Worker-owned pending authority before the + // child completion path returns. The Controller delivery is only + // a wake-up hint; replay is idempotent by notification_request_id. + fallback(method.clone()); let Some(parent_method_tx) = sender.upgrade() else { - fallback(method); return; }; - let fallback = fallback.clone(); tokio::spawn(async move { - if let Err(error) = parent_method_tx.send(method).await { + if parent_method_tx.send(method).await.is_err() { tracing::warn!( - "failed to notify parent Controller; using durable pending authority" + "failed to wake parent Controller after durable notification acceptance" ); - fallback(error.0); } }); } @@ -588,7 +588,7 @@ impl Tool for SubWorkerSpawnTool { let message = format!( "SubWorker `{child_name}` turn ended with status {status:?}. Inspect its committed session with worker-observation tools before making completion decisions." ); - parent_notifications.notify(child_name.clone(), message, true); + parent_notifications.notify(child_name.clone(), message); })), Some(child_workdir_tool_broker.clone()), ) @@ -1199,10 +1199,34 @@ enabled = false drop(parent_method_tx); assert!(parent_method_rx.recv().await.is_none()); - target.notify("child-session".into(), "late completion".to_string(), true); + target.notify("child-session".into(), "late completion".to_string()); assert!(*captured.lock().unwrap()); } + #[tokio::test] + async fn parent_controller_notification_is_durable_before_wakeup_delivery() { + let (parent_method_tx, mut parent_method_rx) = mpsc::channel(1); + let durable = Arc::new(AtomicBool::new(false)); + let durable_for_fallback = Arc::clone(&durable); + let target = ParentNotificationTarget::with_controller_fallback( + parent_method_tx.downgrade(), + ParentNotificationTarget::Durable(Arc::new(move |_| { + durable_for_fallback.store(true, Ordering::Release); + })), + ); + + target.notify("child-session".into(), "completed".into()); + let wakeup = parent_method_rx.recv().await.unwrap(); + assert!(durable.load(Ordering::Acquire)); + assert!(matches!( + wakeup, + Method::NotifyTracked { + source: protocol::AuthenticatedInputSource::SubWorker { session_id }, + .. + } if session_id == "child-session" + )); + } + #[test] fn durable_parent_notification_target_preserves_child_source() { let captured = Arc::new(std::sync::Mutex::new(None)); @@ -1211,13 +1235,12 @@ enabled = false *captured_for_target.lock().unwrap() = Some(method); })); - target.notify("child-session".into(), "completed".into(), true); + target.notify("child-session".into(), "completed".into()); assert!(matches!( captured.lock().unwrap().take(), Some(Method::NotifyTracked { message, - auto_run: true, source: protocol::AuthenticatedInputSource::SubWorker { session_id }, .. }) if session_id == "child-session" && message == "completed" @@ -1361,7 +1384,6 @@ enabled = false completion, Method::NotifyTracked { message, - auto_run: true, source: protocol::AuthenticatedInputSource::SubWorker { session_id }, .. } if session_id == "reviewer-child" diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index f4d32dce6..255933ab2 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -2,7 +2,7 @@ use std::collections::VecDeque; #[cfg(test)] use std::path::Path; use std::path::PathBuf; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -111,8 +111,7 @@ pub(crate) struct PendingNotification { source_namespace: String, pub(crate) message: String, payload_digest: String, - pub(crate) auto_run: bool, - accepted_at_ms: u64, + pub(crate) accepted_at_ms: u64, activation_sequence: u64, pub(crate) provenance: WorkerHistoryProvenance, } @@ -122,7 +121,6 @@ struct NotificationReceipt { notification_request_id: String, source_namespace: String, payload_digest: String, - auto_run: bool, } #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] @@ -133,19 +131,43 @@ pub(crate) struct PendingActivationState { /// UserInput record commits the clearing checkpoint. Restore puts it back /// at the FIFO head. activating: Option, - activating_notification: Option, + #[serde( + default, + rename = "activating_notification", + deserialize_with = "deserialize_activating_notifications" + )] + activating_notifications: VecDeque, pending: VecDeque, pending_notifications: VecDeque, receipts: VecDeque, notification_receipts: VecDeque, } +fn deserialize_activating_notifications<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(serde::Deserialize)] + #[serde(untagged)] + enum OneOrMany { + One(PendingNotification), + Many(VecDeque), + } + + Ok( + match as serde::Deserialize>::deserialize(deserializer)? { + Some(OneOrMany::One(notification)) => VecDeque::from([notification]), + Some(OneOrMany::Many(notifications)) => notifications, + None => VecDeque::new(), + }, + ) +} + impl PendingActivationState { pub(crate) fn snapshot(&self) -> protocol::PendingSubmissionsSnapshot { - let pending_notification = self - .pending_notifications - .iter() - .find(|notification| notification.auto_run); + let pending_notification = self.pending_notifications.front(); let head_id = match (self.pending.front(), pending_notification) { (Some(submission), Some(notification)) if notification.activation_sequence < submission.activation_sequence => @@ -202,14 +224,14 @@ impl PendingActivationState { } } -fn notification_payload_digest(message: &str, auto_run: bool) -> String { +pub const DEFAULT_NOTIFICATION_COALESCE_DELAY: Duration = Duration::from_secs(2); + +fn notification_payload_digest(message: &str) -> String { use sha2::Digest as _; let mut hasher = sha2::Sha256::new(); - hasher.update(if auto_run { - &b"auto\0"[..] - } else { - &b"deferred\0"[..] - }); + // Keep the original real-time notification digest domain so receipts + // accepted before the delivery-mode field was removed still dedupe. + hasher.update(b"auto\0"); hasher.update(message.as_bytes()); hasher .finalize() @@ -338,7 +360,9 @@ use crate::prompt::catalog::{CatalogError, PromptCatalog, WorkspacePromptProject use crate::prompt::source::PromptCatalogSource; use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate}; use crate::runtime::dir; -use crate::runtime::worker_allocation::{self, ScopeAllocationGuard, ScopeLockError}; +use crate::runtime::worker_allocation::{ + self, ScopeAllocationGuard, ScopeLockError, SegmentActivationGuard, +}; use crate::skill::{SkillActivationResponse, SkillClientError}; #[cfg(test)] use async_trait::async_trait; @@ -537,7 +561,7 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync { fn list_workspace_workers( &self, _request: WorkspaceWorkerDiscoveryRequest, - ) -> Result { + ) -> Result { Err(WorkspaceClientError::Unavailable( "Workspace Worker discovery authority is unavailable".to_string(), )) @@ -1033,6 +1057,7 @@ pub struct SegmentState { location: ArcSwap, entries_written: AtomicUsize, append_lock: Mutex<()>, + activation_failed: AtomicBool, } impl SegmentState { @@ -1044,6 +1069,7 @@ impl SegmentState { }), entries_written: AtomicUsize::new(entries_written), append_lock: Mutex::new(()), + activation_failed: AtomicBool::new(false), }) } @@ -1071,6 +1097,19 @@ impl SegmentState { self.entries_written.store(n, Ordering::Release); } + fn ensure_append_allowed(&self) -> Result<(), StoreError> { + if self.activation_failed.load(Ordering::Acquire) { + return Err(StoreError::Io(std::io::Error::other( + "Segment activation is incomplete; restore the Worker before accepting more input", + ))); + } + Ok(()) + } + + fn fail_closed_activation(&self) { + self.activation_failed.store(true, Ordering::Release); + } + fn increment_entries(&self) { self.entries_written.fetch_add(1, Ordering::Release); } @@ -1201,6 +1240,7 @@ where } fn append_entry_locked(&self, entry: LogEntry) -> Result<(), StoreError> { + self.state.ensure_append_allowed()?; let loc = self.state.location(); self.store.append(loc.session_id, loc.segment_id, &entry)?; self.state.increment_entries(); @@ -1233,7 +1273,6 @@ where #[derive(Debug, Clone)] pub(crate) enum PendingActivation { Submission(PendingSubmission), - Notification(PendingNotification), } #[derive(Debug, Clone)] @@ -1418,6 +1457,7 @@ where .append_lock .lock() .expect("segment append lock poisoned"); + self.writer.state.ensure_append_allowed()?; let mut current = self .state .lock() @@ -1530,14 +1570,12 @@ where &self, notification_request_id: String, message: String, - auto_run: bool, ) -> Result { self.accept_notification_from_source( notification_request_id, message, self.direct_client_namespace(), WorkerHistoryProvenance::LegacyUnknown, - auto_run, ) } @@ -1547,7 +1585,6 @@ where message: String, source_namespace: String, provenance: WorkerHistoryProvenance, - auto_run: bool, ) -> Result { if notification_request_id.trim().is_empty() { return Err(PendingSubmissionError::EmptyRequestId); @@ -1555,13 +1592,14 @@ where if notification_request_id.len() > MAX_ACTIVATION_REQUEST_ID_BYTES { return Err(PendingSubmissionError::RequestIdLimit); } - let payload_digest = notification_payload_digest(&message, auto_run); + let payload_digest = notification_payload_digest(&message); let _append_guard = self .writer .state .append_lock .lock() .expect("segment append lock poisoned"); + self.writer.state.ensure_append_allowed()?; let mut state = self .state .lock() @@ -1570,7 +1608,7 @@ where receipt.notification_request_id == notification_request_id && receipt.source_namespace == source_namespace }) { - if receipt.payload_digest != payload_digest || receipt.auto_run != auto_run { + if receipt.payload_digest != payload_digest { return Err(PendingSubmissionError::IdempotencyConflict); } return Ok(false); @@ -1607,7 +1645,6 @@ where source_namespace: source_namespace.clone(), message, payload_digest: payload_digest.clone(), - auto_run, accepted_at_ms: segment_log::now_millis(), activation_sequence, provenance, @@ -1616,7 +1653,6 @@ where notification_request_id, source_namespace, payload_digest, - auto_run, }); state.revision = state.revision.saturating_add(1); if let Err(error) = self.persist_locked(&state) { @@ -1626,57 +1662,53 @@ where Ok(true) } - pub(crate) fn activating_passive_notification_id(&self) -> Option { - self.state + pub(crate) fn oldest_pending_notification_accepted_at_ms(&self) -> Option { + let state = self + .state .lock() - .expect("pending activation state poisoned") - .activating_notification - .as_ref() - .filter(|notification| !notification.auto_run) - .map(|notification| notification.notification_request_id.clone()) + .expect("pending activation state poisoned"); + state + .activating_notifications + .front() + .into_iter() + .chain(state.pending_notifications.front()) + .map(|notification| notification.accepted_at_ms) + .min() } - pub(crate) fn next_passive_notification_identity(&self) -> Option<(String, String)> { - self.state - .lock() - .expect("pending activation state poisoned") - .pending_notifications - .iter() - .find(|notification| !notification.auto_run) - .map(|notification| { - ( - notification.source_namespace.clone(), - notification.notification_request_id.clone(), - ) - }) - } - - pub(crate) fn prepare_notification( + pub(crate) fn prepare_notification_batch( &self, - source_namespace: &str, - notification_request_id: &str, - ) -> Option { + ) -> Vec<(PendingNotification, SessionExtension)> { let mut state = self .state .lock() .expect("pending activation state poisoned"); - if state.activating_notification.is_some() { - return None; + let notifications = state.pending_notifications.drain(..).collect::>(); + if notifications.is_empty() { + return Vec::new(); } - let index = state - .pending_notifications - .iter() - .position(|notification| { - notification.notification_request_id == notification_request_id - && notification.source_namespace == source_namespace - })?; - let notification = state - .pending_notifications - .remove(index) - .expect("located pending notification must exist"); - state.activating_notification = Some(notification.clone()); + state + .activating_notifications + .extend(notifications.iter().cloned()); state.revision = state.revision.saturating_add(1); - Some(notification) + + notifications + .into_iter() + .map(|notification| { + let index = state + .activating_notifications + .iter() + .position(|active| { + active.notification_request_id == notification.notification_request_id + && active.source_namespace == notification.source_namespace + }) + .expect("newly staged notification must be active"); + let mut committed = state.clone(); + committed.activating_notifications.drain(..=index); + committed.revision = committed.revision.saturating_add(1); + (notification, pending_activation_extension(&committed)) + }) + .collect() } pub(crate) fn prepare_next_activation( @@ -1696,48 +1728,15 @@ where if let Some((expected_revision, expected_head_id)) = fence { Self::validate_fence(&state, expected_revision, Some(expected_head_id))?; } - if state.activating.is_some() - || state - .activating_notification - .as_ref() - .is_some_and(|notification| notification.auto_run) - { + if state.activating.is_some() { return Ok(None); } - let has_staged_passive_notification = state.activating_notification.is_some(); - let submission_sequence = state.pending.front().map(|item| item.activation_sequence); - let notification_index = if has_staged_passive_notification { - None - } else { - state - .pending_notifications - .iter() - .position(|item| item.auto_run) + let Some(pending) = state.pending.pop_front() else { + return Ok(None); }; - let notification_sequence = notification_index - .and_then(|index| state.pending_notifications.get(index)) - .map(|item| item.activation_sequence); - if notification_sequence.is_some() - && (submission_sequence.is_none() || notification_sequence < submission_sequence) - { - let notification = state - .pending_notifications - .remove(notification_index.expect("notification sequence came from an item")) - .expect("notification sequence came from an existing item"); - state.activating_notification = Some(notification.clone()); - state.revision = state.revision.saturating_add(1); - return Ok(Some(PendingActivation::Notification(notification))); - } - if submission_sequence.is_some() { - let pending = state - .pending - .pop_front() - .expect("submission sequence came from queue head"); - state.activating = Some(pending.clone()); - state.revision = state.revision.saturating_add(1); - return Ok(Some(PendingActivation::Submission(pending))); - } - Ok(None) + state.activating = Some(pending.clone()); + state.revision = state.revision.saturating_add(1); + Ok(Some(PendingActivation::Submission(pending))) } pub(crate) fn abort_activation(&self, pending: PendingSubmission) { @@ -1811,30 +1810,13 @@ where } } - pub(crate) fn notification_activation_extension(&self) -> SessionExtension { - let state = self - .state - .lock() - .expect("pending activation state poisoned"); - let mut committed = state.clone(); - committed.activating = None; - committed.activating_notification = None; - committed.revision = committed.revision.saturating_add(1); - pending_activation_extension(&committed) - } - - pub(crate) fn finish_notification_activation(&self, notification_request_id: &str) { + pub(crate) fn finish_notification_batch(&self) { let mut state = self .state .lock() .expect("pending activation state poisoned"); - if state - .activating_notification - .as_ref() - .map(|item| item.notification_request_id.as_str()) - == Some(notification_request_id) - { - state.activating_notification = None; + if !state.activating_notifications.is_empty() { + state.activating_notifications.clear(); state.revision = state.revision.saturating_add(1); } } @@ -1948,6 +1930,15 @@ impl PendingSubmissionHandle { }, } } + + #[cfg(test)] + pub(crate) fn persisted_entries_for_test(&self) -> Vec { + let location = self.writer.state.location(); + self.writer + .store + .read_all(location.session_id, location.segment_id) + .expect("read test pending entries") + } } /// Type-erased commit handle for the interceptor. Lets the interceptor commit `SystemItem`s without being generic over the @@ -2055,8 +2046,10 @@ impl WorkerSession { state.pending.push_front(activating); state.revision = state.revision.saturating_add(1); } - if let Some(activating) = state.activating_notification.take() { - state.pending_notifications.push_front(activating); + if !state.activating_notifications.is_empty() { + let mut restored = std::mem::take(&mut state.activating_notifications); + restored.append(&mut state.pending_notifications); + state.pending_notifications = restored; state.revision = state.revision.saturating_add(1); } *self @@ -2104,6 +2097,12 @@ pub(crate) trait SystemPromptContributionSource: Send + Sync { async fn load(&self) -> Option; } +#[derive(Debug, Clone)] +struct PendingCompactionCleanup { + session_id: String, + compaction_id: String, +} + /// An independent agent execution unit. /// /// Holds a [`Engine`] directly and persists session state via @@ -2202,6 +2201,8 @@ pub struct Worker { /// Parent-owned projection/control boundary for observable Internal service Workers. /// Service Workers are never exposed through the model-facing SubWorker control surface. internal_worker_registry: Option>, + /// Cleanup authority survives cancellation of an individual compaction future. + pending_compaction_cleanup: Mutex>, in_flight: Option, /// Monotonic counter incremented by worker event bridges when an /// assistant-side execution artifact becomes visible to clients before @@ -2213,6 +2214,9 @@ pub struct Worker { /// injection into the next LLM request. Shared with the /// WorkerInterceptor installed in `ensure_interceptor_installed`. pending_notifies: NotifyBuffer, + /// Maximum time an idle Worker waits from the first accepted notification + /// so a burst can be delivered as one FIFO batch. + notification_coalesce_delay: Duration, /// Submit-scoped stash for resolver-produced system messages /// (currently `@` file content). `Worker::run` fills this /// before handing off to the worker; `WorkerInterceptor::on_prompt_submit` @@ -2223,6 +2227,10 @@ pub struct Worker { /// interceptor before Agen applies them to live typed history. pending_committed_history: Arc>>>, + /// Machine-wide lock retained after a post-CAS allocation publication + /// failure. Declared before `scope_allocation` so drop releases this nested + /// authority before the outer allocation guard reacquires the same lock. + failed_activation_authority: Mutex>, /// Scope allocation in the machine-wide lock file. `Some` for /// Workers built via `from_manifest` / `from_manifest_spawned` / /// `restore_from_manifest` (production paths); `None` for the @@ -2284,6 +2292,19 @@ pub struct Worker { log_writer: Option>, } +impl Drop for Worker { + fn drop(&mut self) { + // A failed activation authority nests the same machine-wide lock used by + // `ScopeAllocationGuard::drop`. Release the nested guard first so Worker + // teardown cannot self-deadlock while removing its outer allocation. + let authority = match self.failed_activation_authority.get_mut() { + Ok(authority) => authority.take(), + Err(poisoned) => poisoned.into_inner().take(), + }; + drop(authority); + } +} + impl Worker { pub async fn stop_feature_runtime(&mut self, reason: impl Into) { if let Some(hooks) = self.hook_registry.clone() { @@ -2476,11 +2497,14 @@ impl Worker { alerter: None, working_event_tx: None, internal_worker_registry: None, + pending_compaction_cleanup: Mutex::new(None), in_flight: None, ai_activity_counter: Arc::new(AtomicUsize::new(0)), pending_notifies: NotifyBuffer::new(), + notification_coalesce_delay: DEFAULT_NOTIFICATION_COALESCE_DELAY, pending_attachments: Arc::new(Mutex::new(Vec::::new())), pending_committed_history: Arc::new(Mutex::new(std::collections::VecDeque::new())), + failed_activation_authority: Mutex::new(None), scope_allocation: None, callback_socket: None, runtime_ticket_role: None, @@ -3004,7 +3028,7 @@ impl Worker { if !pending_state.pending.is_empty() || !pending_state.pending_notifications.is_empty() || pending_state.activating.is_some() - || pending_state.activating_notification.is_some() + || !pending_state.activating_notifications.is_empty() || !pending_state.receipts.is_empty() || !pending_state.notification_receipts.is_empty() { @@ -3381,8 +3405,21 @@ impl Worker { /// `Item::system_message` just before the next LLM request, via /// `WorkerInterceptor::pending_history_appends`. See [`NotifyBuffer`] /// for overflow behaviour and the lane-of-record rationale. - pub fn push_notify(&self, message: String, auto_run: bool) { - self.pending_notifies.push_notify(message, auto_run); + /// Configure the fixed idle notification coalescing window. + /// + /// The deadline is derived from the oldest durable notification receipt, + /// so restart restores the remaining window rather than starting it over. + pub fn with_notification_coalesce_delay(mut self, delay: Duration) -> Self { + self.notification_coalesce_delay = delay; + self + } + + pub(crate) fn notification_coalesce_delay(&self) -> Duration { + self.notification_coalesce_delay + } + + pub fn push_notify(&self, message: String) { + self.pending_notifies.push_notify(message); } /// Push an agent-visible typed `WorkerEvent` entry onto the pending buffer. @@ -3643,7 +3680,7 @@ impl Worker { self.materialize_durable_session_head().await?; self.ensure_interceptor_installed(); if self.should_pre_run_compact() { - self.try_pre_run_compact().await; + self.try_pre_run_compact().await?; } Ok(()) } @@ -4446,8 +4483,15 @@ impl Worker { let _rewrite_guard = self .prepare_session_rewrite(SessionRewriteKind::Fork) .await?; - let w = self.engine.as_ref().unwrap(); + let segment_state = Arc::clone(&self.segment_state); + let _append_guard = segment_state + .append_lock + .lock() + .expect("segment append lock poisoned"); + segment_state.ensure_append_allowed()?; + let loc = segment_state.location(); let fork_segment_id = session_store::new_segment_id(); + let w = self.engine.as_ref().unwrap(); let entry = LogEntry::AnnotatedSegmentStart { ts: segment_log::now_millis(), session_id: loc.session_id, @@ -4478,24 +4522,47 @@ impl Worker { { initial_entries.push(checkpoint); } + initial_entries.push(LogEntry::Extension { + ts: segment_log::now_millis(), + domain: SESSION_PENDING_ACTIVATIONS_EXTENSION_DOMAIN.to_owned(), + payload: serde_json::to_value( + &*self + .session + .pending_activations + .lock() + .expect("pending activation state poisoned"), + ) + .map_err(|error| { + WorkerError::InvalidState(format!( + "serialize pending submissions during automatic fork: {error}" + )) + })?, + }); self.store .create_segment(loc.session_id, fork_segment_id, &initial_entries) .map_err(WorkerError::from)?; - self.segment_state.set_location(SegmentLocation { + let new_location = SegmentLocation { session_id: loc.session_id, segment_id: fork_segment_id, - }); + }; + let allocation_activation = self + .scope_allocation + .as_ref() + .map(|allocation| allocation.begin_segment_activation(loc.segment_id, fork_segment_id)) + .transpose()?; + self.compare_and_swap_worker_metadata_segment(loc, new_location)?; + if let Some(allocation_activation) = allocation_activation { + if let Err(error) = allocation_activation.commit() { + let (source, authority) = error.into_parts(); + self.fail_closed_segment_activation(authority); + return Err(WorkerError::SegmentActivationIncomplete { source }); + } + } + self.segment_state.set_location(new_location); self.segment_state .set_entries_written(initial_entries.len()); self.sink .reset_with_initial_entries(initial_entries.clone()); - if self.scope_allocation.is_some() { - worker_allocation::update_segment(&self.manifest.worker.name, fork_segment_id)?; - } - self.write_worker_metadata_active(SegmentLocation { - session_id: loc.session_id, - segment_id: fork_segment_id, - })?; Ok(()) } @@ -4694,16 +4761,18 @@ impl Worker { /// This used to run in the controller's post-run path. Keeping it here /// preserves the ordering requirement that the next turn starts with a /// compacted history, without introducing a separate Busy controller state. - /// Best-effort: failures are logged and surfaced, but do not abort the - /// user turn that triggered the check. - pub async fn try_pre_run_compact(&mut self) { + /// Fatal activation failures are returned to the controller so it cannot + /// publish Idle or start the triggering turn with inconsistent authority. + pub async fn try_pre_run_compact(&mut self) -> Result<(), WorkerError> { let Some(state) = self.compact_state.clone() else { - return; + return Ok(()); }; let current_tokens = self.total_tokens().tokens; match state.evaluate_pre_run(current_tokens) { AutomaticCompactDecision::Start(AutomaticCompactTrigger::PreRun) => {} - AutomaticCompactDecision::Continue | AutomaticCompactDecision::Block(_) => return, + AutomaticCompactDecision::Continue | AutomaticCompactDecision::Block(_) => { + return Ok(()); + } AutomaticCompactDecision::Start(AutomaticCompactTrigger::RequestThreshold) => { unreachable!("pre-run evaluation returned request-threshold trigger") } @@ -4724,6 +4793,7 @@ impl Worker { completed, "automatic compaction must complete a claimed attempt" ); + Ok(()) } Err(e) => { warn!(error = %e, "Proactive pre-run compaction failed"); @@ -4742,6 +4812,15 @@ impl Worker { completed, "automatic compaction must complete a claimed attempt" ); + if matches!( + &e, + WorkerError::SegmentActivationIncomplete { .. } + | WorkerError::CompactionCleanupPending { .. } + ) { + Err(e) + } else { + Ok(()) + } } } } @@ -5027,6 +5106,7 @@ impl Worker { mut cancel: Option>, trigger: CompactionTrigger, ) -> Result { + self.release_pending_compaction_service().await?; let _rewrite_guard = self .prepare_session_rewrite(SessionRewriteKind::Compact) .await?; @@ -5090,15 +5170,23 @@ impl Worker { match outcome { Ok((new_segment_id, _summary, stats)) => { debug_assert_eq!(lifecycle.state, CompactionLifecycleState::Done); + self.set_compaction_progress(None); for metric in attempt.success_metrics(new_segment_id, started.elapsed(), &stats) { self.try_record_metric(&metric); } self.usage_tracker .note_compaction_correlation_id(attempt.correlation_id().to_string()); - self.release_compaction_service(&lifecycle).await; Ok(new_segment_id) } Err(error) => { + let error = if matches!(&error, WorkerError::CompactionCleanupPending { .. }) { + error + } else { + match self.release_compaction_service(&mut lifecycle).await { + Ok(()) => error, + Err(cleanup_error) => cleanup_error, + } + }; let observed_segment_id = self.segment_state.location().segment_id; for metric in attempt.failure_metrics( observed_segment_id, @@ -5108,40 +5196,112 @@ impl Worker { self.try_record_metric(&metric); } lifecycle.revision = lifecycle.revision.saturating_add(1); - lifecycle.state = if matches!(error, WorkerError::CompactCancelled) { + lifecycle.state = if matches!(&error, WorkerError::CompactCancelled) { CompactionLifecycleState::Interrupted } else { CompactionLifecycleState::Failed }; lifecycle.ended_at_ms = Some(segment_log::now_millis()); - self.set_compaction_progress(None); - self.release_compaction_service(&lifecycle).await; + if !matches!(&error, WorkerError::CompactionCleanupPending { .. }) { + self.set_compaction_progress(None); + } Err(error) } } } - async fn release_compaction_service(&self, lifecycle: &CompactionLifecycle) { - let Some(session_id) = lifecycle - .internal_worker - .as_ref() - .map(|worker| worker.session_id.as_str()) - else { - return; + pub(crate) fn has_pending_compaction_cleanup(&self) -> bool { + self.pending_compaction_cleanup + .lock() + .expect("pending compaction cleanup mutex poisoned") + .is_some() + } + + fn fail_closed_segment_activation(&self, authority: SegmentActivationGuard) { + *self + .failed_activation_authority + .lock() + .expect("failed activation authority mutex poisoned") = Some(authority); + self.segment_state.fail_closed_activation(); + } + + pub(crate) fn has_failed_segment_activation(&self) -> bool { + self.segment_state.activation_failed.load(Ordering::Acquire) + } + + pub(crate) async fn retry_pending_compaction_cleanup(&self) -> Result<(), WorkerError> { + self.release_pending_compaction_service().await + } + + pub(crate) async fn finish_pending_compaction_cleanup_for_shutdown( + &self, + ) -> Result<(), WorkerError> { + loop { + match self.retry_pending_compaction_cleanup().await { + Ok(()) => return Ok(()), + Err(error) => { + warn!( + error = %error, + "shutdown remains blocked on compaction service cleanup" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + } + } + + async fn release_pending_compaction_service(&self) -> Result<(), WorkerError> { + let cleanup = self + .pending_compaction_cleanup + .lock() + .expect("pending compaction cleanup mutex poisoned") + .clone(); + let Some(cleanup) = cleanup else { + return Ok(()); }; let Some(registry) = &self.internal_worker_registry else { - return; + return Err(WorkerError::CompactionCleanupPending { + source: ScopeLockError::Io(std::io::Error::other( + "compaction registry unavailable during cleanup", + )), + }); }; - if let Err(error) = registry.stop_service(session_id).await { - warn!( - compaction_id = %lifecycle.compaction_id, - internal_worker_session_id = %session_id, - error = %error, - "failed to release terminal compaction Internal Worker" - ); + match registry.stop_service(&cleanup.session_id).await { + Ok(_) => { + let mut pending = self + .pending_compaction_cleanup + .lock() + .expect("pending compaction cleanup mutex poisoned"); + if pending + .as_ref() + .is_some_and(|pending| pending.session_id == cleanup.session_id) + { + *pending = None; + } + Ok(()) + } + Err(source) => { + warn!( + compaction_id = %cleanup.compaction_id, + error = %source, + "compaction service cleanup failed; retaining cleanup authority" + ); + Err(WorkerError::CompactionCleanupPending { + source: ScopeLockError::Io(source), + }) + } } } + async fn release_compaction_service( + &self, + lifecycle: &mut CompactionLifecycle, + ) -> Result<(), WorkerError> { + self.release_pending_compaction_service().await?; + lifecycle.internal_worker = None; + Ok(()) + } + async fn compact_impl( &mut self, retained_tokens: u64, @@ -5377,6 +5537,14 @@ impl Worker { .map_err(|error| WorkerError::InvalidState(error.to_string()))?; lifecycle.revision = lifecycle.revision.saturating_add(1); lifecycle.internal_worker = Some(internal_ref); + let cleanup = PendingCompactionCleanup { + session_id: handle.session_id_string(), + compaction_id: lifecycle.compaction_id.clone(), + }; + *self + .pending_compaction_cleanup + .lock() + .expect("pending compaction cleanup mutex poisoned") = Some(cleanup); self.set_compaction_progress(Some(InFlightCompaction { phase: CompactionPhase::Summarizing, started_at_ms: lifecycle.started_at_ms, @@ -5384,7 +5552,8 @@ impl Worker { })); if let Err(error) = handle.send(summary_input.text).await { - let _ = registry.remove_service(&handle.session_id_string()); + // Keep the registry record and Worker-owned cleanup authority. The + // outer compaction boundary performs the single stop attempt. return Err(WorkerError::InvalidState(error.to_string())); } match handle.wait_until_idle().await { @@ -5675,8 +5844,31 @@ impl Worker { // rotate: create on disk, swap location, reset the broadcast // sink so existing subscribers see the new `SegmentStart // { compacted_from }` and reset their view. + // The Compactor has produced and validated its output. Retire it before + // activation so no internal process or registry authority survives into + // the replacement's live lifetime. Cleanup failures retain this future + // (and Busy state) until a retry succeeds. + self.release_compaction_service(lifecycle).await?; + + self.set_compaction_progress(Some(InFlightCompaction { + phase: CompactionPhase::Committing, + started_at_ms: lifecycle.started_at_ms, + trigger, + })); + + // Activation lock order is append barrier -> allocation table -> metadata. + // From this point through live publication, every accepted Submit/Notify + // either completed on the source Segment or waits to observe the fully + // activated replacement. + let segment_state = Arc::clone(&self.segment_state); + let _append_guard = segment_state + .append_lock + .lock() + .expect("segment append lock poisoned"); + segment_state.ensure_append_allowed()?; + let new_segment_id = session_store::new_segment_id(); - let old_loc = self.segment_state.location(); + let old_loc = segment_state.location(); let source_turn_count = self.engine.as_ref().unwrap().turn_count(); let w = self.engine.as_ref().unwrap(); let entry = LogEntry::AnnotatedSegmentStart { @@ -5738,11 +5930,6 @@ impl Worker { })?, }); } - self.set_compaction_progress(Some(InFlightCompaction { - phase: CompactionPhase::Committing, - started_at_ms: lifecycle.started_at_ms, - trigger, - })); self.store .create_segment(old_loc.session_id, new_segment_id, &initial_entries)?; @@ -5756,21 +5943,34 @@ impl Worker { session_id: old_loc.session_id, segment_id: new_segment_id, }; - // The writer lease is stable for the Worker lifetime and is keyed by - // worker name. Compaction must not transfer or rewrite that lease: the - // active Segment is derived exclusively from the CAS-protected Worker - // metadata pointer below. A lost CAS therefore leaves every live and - // durable authority on the previous Segment. + // The machine-wide allocation lock is acquired only after staging, but + // before the durable commit point. Restore admission therefore cannot + // race the metadata CAS and allocation hand-off. + let allocation_activation = self + .scope_allocation + .as_ref() + .map(|allocation| { + allocation.begin_segment_activation(old_loc.segment_id, new_segment_id) + }) + .transpose()?; self.compare_and_swap_worker_metadata_segment(old_loc, new_location)?; + if let Some(allocation_activation) = allocation_activation { + if let Err(error) = allocation_activation.commit() { + let (source, authority) = error.into_parts(); + // Metadata already names the complete replacement. Do not allow + // the old in-memory writer to accept another byte while durable + // authorities disagree; retain the allocation lock so restore + // admission cannot register a competing writer. + self.fail_closed_segment_activation(authority); + return Err(WorkerError::SegmentActivationIncomplete { source }); + } + } // All live mutations after the durable commit are infallible and happen // before the replacement SegmentStart is broadcast. This keeps the // append destination, Session projection, Engine cache identity, and // reconnect snapshot on one side of the same activation boundary. - self.segment_state.set_location(SegmentLocation { - session_id: old_loc.session_id, - segment_id: new_segment_id, - }); + self.segment_state.set_location(new_location); self.segment_state .set_entries_written(initial_entries.len()); self.user_segments = retained_user_segments; @@ -5796,7 +5996,6 @@ impl Worker { lifecycle.revision = lifecycle.revision.saturating_add(1); lifecycle.state = CompactionLifecycleState::Done; lifecycle.ended_at_ms = Some(segment_log::now_millis()); - self.set_compaction_progress(None); Ok(( new_segment_id, summary_text, @@ -5974,11 +6173,14 @@ where alerter: None, working_event_tx: None, internal_worker_registry: None, + pending_compaction_cleanup: Mutex::new(None), in_flight: None, ai_activity_counter: Arc::new(AtomicUsize::new(0)), pending_notifies: NotifyBuffer::new(), + notification_coalesce_delay: DEFAULT_NOTIFICATION_COALESCE_DELAY, pending_attachments: Arc::new(Mutex::new(Vec::::new())), pending_committed_history: Arc::new(Mutex::new(std::collections::VecDeque::new())), + failed_activation_authority: Mutex::new(None), scope_allocation: Some(scope_allocation), callback_socket: None, runtime_ticket_role: None, @@ -6059,11 +6261,14 @@ where alerter: None, working_event_tx: None, internal_worker_registry: None, + pending_compaction_cleanup: Mutex::new(None), in_flight: None, ai_activity_counter: Arc::new(AtomicUsize::new(0)), pending_notifies: NotifyBuffer::new(), + notification_coalesce_delay: DEFAULT_NOTIFICATION_COALESCE_DELAY, pending_attachments: Arc::new(Mutex::new(Vec::::new())), pending_committed_history: Arc::new(Mutex::new(std::collections::VecDeque::new())), + failed_activation_authority: Mutex::new(None), scope_allocation: None, callback_socket: None, runtime_ticket_role: None, @@ -6179,11 +6384,14 @@ where alerter: None, working_event_tx: None, internal_worker_registry: None, + pending_compaction_cleanup: Mutex::new(None), in_flight: None, ai_activity_counter: Arc::new(AtomicUsize::new(0)), pending_notifies: NotifyBuffer::new(), + notification_coalesce_delay: DEFAULT_NOTIFICATION_COALESCE_DELAY, pending_attachments: Arc::new(Mutex::new(Vec::::new())), pending_committed_history: Arc::new(Mutex::new(std::collections::VecDeque::new())), + failed_activation_authority: Mutex::new(None), scope_allocation: Some(scope_allocation), callback_socket: Some(callback_socket), runtime_ticket_role: None, @@ -6556,11 +6764,14 @@ where alerter: None, working_event_tx: None, internal_worker_registry: None, + pending_compaction_cleanup: Mutex::new(None), in_flight: None, ai_activity_counter: Arc::new(AtomicUsize::new(0)), pending_notifies: NotifyBuffer::new(), + notification_coalesce_delay: DEFAULT_NOTIFICATION_COALESCE_DELAY, pending_attachments: Arc::new(Mutex::new(Vec::::new())), pending_committed_history: Arc::new(Mutex::new(std::collections::VecDeque::new())), + failed_activation_authority: Mutex::new(None), scope_allocation: Some(scope_allocation), callback_socket: None, runtime_ticket_role: None, @@ -6639,7 +6850,6 @@ where self.push_notify( "Restored Worker state contained missing or unreachable delegated child Workers; their delegated write scopes were reclaimed before resume." .to_string(), - false, ); Ok(()) } @@ -7198,7 +7408,9 @@ fn compact_failure_category(error: &WorkerError) -> CompactFailureCategory { WorkerError::CompactResultContextTooLarge { .. } => { CompactFailureCategory::ResultContextTooLarge } - WorkerError::WorkerStore(_) | WorkerError::CompactActiveSegmentChanged => { + WorkerError::WorkerStore(_) + | WorkerError::CompactActiveSegmentChanged + | WorkerError::SegmentActivationIncomplete { .. } => { CompactFailureCategory::ActiveSegmentCommit } WorkerError::Store(_) => CompactFailureCategory::Storage, @@ -7304,6 +7516,20 @@ pub enum WorkerError { #[error(transparent)] ScopeLock(#[from] ScopeLockError), + #[error( + "Segment activation could not be completed; the Worker must be restored before accepting more input" + )] + SegmentActivationIncomplete { + #[source] + source: ScopeLockError, + }, + + #[error("compaction cleanup is pending; retry or shut down the Worker")] + CompactionCleanupPending { + #[source] + source: ScopeLockError, + }, + #[error(transparent)] PromptCatalog(#[from] CatalogError), @@ -8351,6 +8577,231 @@ mod build_summary_prompt_tests { assert_eq!(restored.active_run_turn_count, None); } + #[tokio::test] + async fn post_metadata_allocation_failure_fences_appends_and_restart_converges() { + let dir = tempfile::tempdir().unwrap(); + let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap(); + let mut worker = Worker::new( + minimal_manifest(), + Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(NoopClient), + store, + WorkerWorkspaceContext::no_workspace(), + WorkerFilesystemAuthority::None, + Scope::empty(), + ) + .await + .unwrap(); + worker.ensure_segment_head().await.unwrap(); + let old_segment = worker.segment_id(); + let replacement = session_store::new_segment_id(); + let old_location = SegmentLocation { + session_id: worker.session_id(), + segment_id: old_segment, + }; + let replacement_location = SegmentLocation { + session_id: worker.session_id(), + segment_id: replacement, + }; + let persisted_metadata = Arc::new(Mutex::new(WorkerActiveSegmentRef::active_segment( + old_location.session_id, + old_location.segment_id, + ))); + let metadata_for_cas = Arc::clone(&persisted_metadata); + worker.worker_metadata_segment_cas = + Some(Arc::new(move |_worker_name, expected, replacement| { + let mut persisted = metadata_for_cas.lock().unwrap(); + if &*persisted != expected { + return Ok(false); + } + *persisted = replacement; + Ok(true) + })); + let lock_path = dir.path().join("workers.json"); + let allocation = worker_allocation::install_top_level_at_for_test( + lock_path.clone(), + "test-worker".into(), + std::process::id(), + dir.path().join("worker.sock"), + Vec::new(), + old_segment, + ) + .unwrap(); + let activation = allocation + .begin_segment_activation(old_segment, replacement) + .unwrap(); + worker.scope_allocation = Some(allocation); + worker + .compare_and_swap_worker_metadata_segment(old_location, replacement_location) + .unwrap(); + assert_eq!( + *persisted_metadata.lock().unwrap(), + WorkerActiveSegmentRef::active_segment( + replacement_location.session_id, + replacement_location.segment_id, + ) + ); + worker_allocation::fail_next_save_for_test(&lock_path); + let (source, authority) = activation.commit().unwrap_err().into_parts(); + assert!(matches!(source, ScopeLockError::Io(_))); + worker.fail_closed_segment_activation(authority); + assert!(worker.segment_state.ensure_append_allowed().is_err()); + + let (sent, received) = std::sync::mpsc::channel(); + let teardown = std::thread::spawn(move || { + drop(worker); + sent.send(()).unwrap(); + }); + received + .recv_timeout(Duration::from_secs(1)) + .expect("Worker teardown must not deadlock on nested allocation locks"); + teardown.join().unwrap(); + + // Process-stop releases the retained authority and the old allocation; + // restart follows the replacement named by committed metadata. + let recovered = worker_allocation::install_top_level_at_for_test( + lock_path, + "test-worker".into(), + std::process::id(), + dir.path().join("worker-restored.sock"), + Vec::new(), + replacement, + ) + .unwrap(); + drop( + recovered + .begin_segment_activation(replacement, session_store::new_segment_id()) + .unwrap(), + ); + drop(recovered); + } + + #[tokio::test] + async fn crash_after_allocation_before_live_switch_recovers_committed_replacement() { + let dir = tempfile::tempdir().unwrap(); + let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap(); + let mut worker = Worker::new( + minimal_manifest(), + Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(NoopClient), + store, + WorkerWorkspaceContext::no_workspace(), + WorkerFilesystemAuthority::None, + Scope::empty(), + ) + .await + .unwrap(); + worker.ensure_segment_head().await.unwrap(); + let old_location = worker.segment_state.location(); + let replacement_location = SegmentLocation { + session_id: old_location.session_id, + segment_id: session_store::new_segment_id(), + }; + let persisted_metadata = Arc::new(Mutex::new(WorkerActiveSegmentRef::active_segment( + old_location.session_id, + old_location.segment_id, + ))); + let metadata_for_cas = Arc::clone(&persisted_metadata); + worker.worker_metadata_segment_cas = + Some(Arc::new(move |_worker_name, expected, replacement| { + let mut persisted = metadata_for_cas.lock().unwrap(); + if &*persisted != expected { + return Ok(false); + } + *persisted = replacement; + Ok(true) + })); + let lock_path = dir.path().join("workers.json"); + let allocation = worker_allocation::install_top_level_at_for_test( + lock_path.clone(), + "test-worker".into(), + std::process::id(), + dir.path().join("worker.sock"), + Vec::new(), + old_location.segment_id, + ) + .unwrap(); + let activation = allocation + .begin_segment_activation(old_location.segment_id, replacement_location.segment_id) + .unwrap(); + worker.scope_allocation = Some(allocation); + worker + .compare_and_swap_worker_metadata_segment(old_location, replacement_location) + .unwrap(); + activation.commit().unwrap(); + + // Simulated crash point: durable metadata and allocation moved, while the + // in-memory append destination still names the source Segment. + assert_eq!(worker.segment_id(), old_location.segment_id); + assert_eq!( + *persisted_metadata.lock().unwrap(), + WorkerActiveSegmentRef::active_segment( + replacement_location.session_id, + replacement_location.segment_id, + ) + ); + drop(worker); + + let recovered = worker_allocation::install_top_level_at_for_test( + lock_path, + "test-worker".into(), + std::process::id(), + dir.path().join("worker-restored.sock"), + Vec::new(), + replacement_location.segment_id, + ) + .unwrap(); + drop( + recovered + .begin_segment_activation( + replacement_location.segment_id, + session_store::new_segment_id(), + ) + .unwrap(), + ); + drop(recovered); + } + + #[tokio::test] + async fn shutdown_cleanup_stays_blocked_until_registered_service_stops() { + let dir = tempfile::tempdir().unwrap(); + let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap(); + let mut worker = Worker::new( + minimal_manifest(), + Engine::<_, Mutable, SessionHistoryMetadata>::new_annotated(NoopClient), + store, + WorkerWorkspaceContext::no_workspace(), + WorkerFilesystemAuthority::None, + Scope::empty(), + ) + .await + .unwrap(); + let registry = crate::spawn::registry::SpawnedWorkerRegistry::new_for_internal_services(); + let (cleanup_session_id, _events) = registry.install_service_for_test(); + registry.fail_service_stops_for_test(&cleanup_session_id, 4); + worker.internal_worker_registry = Some(Arc::clone(®istry)); + *worker + .pending_compaction_cleanup + .lock() + .expect("pending cleanup lock poisoned") = Some(PendingCompactionCleanup { + session_id: cleanup_session_id.clone(), + compaction_id: "compaction".into(), + }); + + let cleanup = worker.finish_pending_compaction_cleanup_for_shutdown(); + tokio::pin!(cleanup); + tokio::select! { + result = &mut cleanup => panic!("shutdown cleanup completed before injected failures: {result:?}"), + _ = tokio::time::sleep(Duration::from_millis(75)) => {} + } + assert!(worker.has_pending_compaction_cleanup()); + assert!(registry.has_service_for_test(&cleanup_session_id)); + tokio::time::timeout(Duration::from_secs(1), cleanup) + .await + .expect("shutdown cleanup must converge after retry") + .unwrap(); + assert!(!worker.has_pending_compaction_cleanup()); + assert!(!registry.has_service_for_test(&cleanup_session_id)); + } + #[tokio::test] async fn auto_fork_checkpoints_interrupted_run_budget_for_restore() { let dir = tempfile::tempdir().unwrap(); @@ -8402,8 +8853,9 @@ mod build_summary_prompt_tests { active_turn_count: 3, total_turn_count: 7, .. - } - ] + }, + LogEntry::Extension { domain, .. } + ] if domain == SESSION_PENDING_ACTIVATIONS_EXTENSION_DOMAIN )); let restored = session_store::collect_state(&fork_entries); assert!(restored.last_run_interrupted); @@ -8994,7 +9446,7 @@ mod build_summary_prompt_tests { append_user_turn(&worker, 20, "second message"); worker .pending_submission_handle() - .accept_notification("notification-1".into(), "keep me".into(), true) + .accept_notification("notification-1".into(), "keep me".into()) .unwrap(); let (head_entries, targets) = worker.list_rewind_targets().unwrap(); @@ -10120,6 +10572,82 @@ mod build_summary_prompt_tests { ); } + #[test] + fn submit_and_notification_wait_for_activation_barrier_then_append_to_replacement() { + let temp = tempfile::tempdir().unwrap(); + let handle = PendingSubmissionHandle::for_test(temp.path()); + let old_location = handle.writer.state.location(); + let replacement = session_store::new_segment_id(); + handle + .writer + .store + .create_segment(old_location.session_id, replacement, &[]) + .unwrap(); + let old_entries = handle + .writer + .store + .read_all(old_location.session_id, old_location.segment_id) + .unwrap() + .len(); + let append_guard = handle.writer.state.append_lock.lock().unwrap(); + let submit_handle = handle.clone(); + let notify_handle = handle.clone(); + let (started, starts) = std::sync::mpsc::channel(); + let (finished, finishes) = std::sync::mpsc::channel(); + let submit_started = started.clone(); + let submit_finished = finished.clone(); + let submit = std::thread::spawn(move || { + submit_started.send(()).unwrap(); + submit_handle + .accept( + "barrier-submit".into(), + vec![Segment::text("queued")], + false, + ) + .unwrap(); + submit_finished.send(()).unwrap(); + }); + let notify = std::thread::spawn(move || { + started.send(()).unwrap(); + notify_handle + .accept_notification("barrier-notify".into(), "completed".into()) + .unwrap(); + finished.send(()).unwrap(); + }); + starts.recv_timeout(Duration::from_secs(1)).unwrap(); + starts.recv_timeout(Duration::from_secs(1)).unwrap(); + assert!(finishes.recv_timeout(Duration::from_millis(50)).is_err()); + + handle.writer.state.set_location(SegmentLocation { + session_id: old_location.session_id, + segment_id: replacement, + }); + drop(append_guard); + finishes.recv_timeout(Duration::from_secs(1)).unwrap(); + finishes.recv_timeout(Duration::from_secs(1)).unwrap(); + submit.join().unwrap(); + notify.join().unwrap(); + + assert_eq!( + handle + .writer + .store + .read_all(old_location.session_id, old_location.segment_id) + .unwrap() + .len(), + old_entries + ); + assert_eq!( + handle + .writer + .store + .read_all(old_location.session_id, replacement) + .unwrap() + .len(), + 2 + ); + } + #[test] fn pending_submission_queue_is_durable_idempotent_and_bounded() { let temp = tempfile::tempdir().unwrap(); @@ -10247,7 +10775,6 @@ mod build_summary_prompt_tests { "notice".into(), "account:account-a".into(), account_a.clone(), - false, ) .unwrap() ); @@ -10258,7 +10785,6 @@ mod build_summary_prompt_tests { "notice".into(), "account:account-a".into(), account_a.clone(), - false, ) .unwrap() ); @@ -10269,7 +10795,6 @@ mod build_summary_prompt_tests { "notice".into(), "account:account-b".into(), account_b.clone(), - false, ) .unwrap() ); @@ -10279,45 +10804,43 @@ mod build_summary_prompt_tests { } #[test] - fn notification_and_submit_share_activation_order_and_notification_dedupes() { + fn notification_batch_preserves_fifo_order_and_notification_dedupes() { let temp = tempfile::tempdir().unwrap(); let handle = PendingSubmissionHandle::for_test(temp.path()); assert!( handle - .accept_notification("notification-1".into(), "notice".into(), true) + .accept_notification("notification-1".into(), "notice".into()) .unwrap() ); assert!( !handle - .accept_notification("notification-1".into(), "notice".into(), true) + .accept_notification("notification-1".into(), "notice".into()) .unwrap() ); assert!(matches!( - handle.accept_notification("notification-1".into(), "different".into(), true), - Err(PendingSubmissionError::IdempotencyConflict) - )); - assert!(matches!( - handle.accept_notification("notification-1".into(), "notice".into(), false), + handle.accept_notification("notification-1".into(), "different".into()), Err(PendingSubmissionError::IdempotencyConflict) )); + assert!( + handle + .accept_notification("notification-2".into(), "second notice".into()) + .unwrap() + ); handle .accept("request-1".into(), vec![Segment::text("submit")], false) .unwrap(); - let first = handle.prepare_next_activation(None).unwrap().unwrap(); - assert!(matches!( - first, - PendingActivation::Notification(PendingNotification { ref message, .. }) - if message == "notice" - )); - let committed = handle.notification_activation_extension(); + let notifications = handle.prepare_notification_batch(); + assert_eq!(notifications.len(), 2); + assert_eq!(notifications[0].0.message, "notice"); + assert_eq!(notifications[1].0.message, "second notice"); let committed_state: PendingActivationState = - serde_json::from_value(committed.payload).unwrap(); + serde_json::from_value(notifications[1].1.payload.clone()).unwrap(); assert!(committed_state.pending_notifications.is_empty()); - assert!(committed_state.activating_notification.is_none()); - handle.finish_notification_activation("notification-1"); - let second = handle.prepare_next_activation(None).unwrap().unwrap(); - assert!(matches!(second, PendingActivation::Submission(_))); + assert!(committed_state.activating_notifications.is_empty()); + handle.finish_notification_batch(); + let submission = handle.prepare_next_activation(None).unwrap().unwrap(); + assert!(matches!(submission, PendingActivation::Submission(_))); } #[test] @@ -10337,18 +10860,17 @@ mod build_summary_prompt_tests { was_queued: false, input: vec![Segment::text("first")], }), - activating_notification: Some(PendingNotification { + activating_notifications: VecDeque::from([PendingNotification { notification_request_id: "notification-1".into(), source_namespace: "account:account-1".into(), message: "deferred notice".into(), - payload_digest: notification_payload_digest("deferred notice", false), - auto_run: false, + payload_digest: notification_payload_digest("deferred notice"), accepted_at_ms: 3, activation_sequence: 2, provenance: WorkerHistoryProvenance::HumanInput { account_id: "account-1".into(), }, - }), + }]), pending: VecDeque::from([PendingSubmission { submission_request_id: "request-2".into(), source_namespace: "direct:test".into(), @@ -10365,13 +10887,19 @@ mod build_summary_prompt_tests { notification_receipts: VecDeque::from([NotificationReceipt { notification_request_id: "notification-1".into(), source_namespace: "account:account-1".into(), - payload_digest: notification_payload_digest("deferred notice", false), - auto_run: false, + payload_digest: notification_payload_digest("deferred notice"), }]), }; + let mut payload = serde_json::to_value(state).unwrap(); + let legacy_activating = payload["activating_notification"] + .as_array_mut() + .expect("new state serializes an activating notification array") + .pop() + .expect("one activating notification"); + payload["activating_notification"] = legacy_activating; session.restore_pending_activations(&[( SESSION_PENDING_ACTIVATIONS_EXTENSION_DOMAIN.into(), - serde_json::to_value(state).unwrap(), + payload, )]); let state = session .pending_activations @@ -10381,9 +10909,8 @@ mod build_summary_prompt_tests { assert_eq!(state.pending.len(), 2); assert_eq!(state.pending[0].submission_id, "submission-1"); assert_eq!(state.pending[1].submission_id, "submission-2"); - assert!(state.activating_notification.is_none()); + assert!(state.activating_notifications.is_empty()); assert_eq!(state.pending_notifications.len(), 1); - assert!(!state.pending_notifications[0].auto_run); assert!(matches!( state.pending_notifications[0].provenance, WorkerHistoryProvenance::HumanInput { ref account_id } if account_id == "account-1" diff --git a/crates/worker/tests/compact_events_test.rs b/crates/worker/tests/compact_events_test.rs index 416437931..c6c37367c 100644 --- a/crates/worker/tests/compact_events_test.rs +++ b/crates/worker/tests/compact_events_test.rs @@ -344,7 +344,10 @@ target = "./" permission = "write" "#; -async fn make_worker_with_manifest(manifest_toml: &str, client: C) -> Worker +async fn make_worker_with_manifest_and_store( + manifest_toml: &str, + client: C, +) -> (Worker, TestStore) where C: LlmClient + Clone + Send + Sync + 'static, { @@ -364,6 +367,7 @@ where let worker = Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client); + let observed_store = store.clone(); let mut worker = Worker::new( manifest, worker, @@ -375,7 +379,16 @@ where .await .unwrap(); worker.enable_worker_metadata_write_through().unwrap(); - worker + (worker, observed_store) +} + +async fn make_worker_with_manifest(manifest_toml: &str, client: C) -> Worker +where + C: LlmClient + Clone + Send + Sync + 'static, +{ + make_worker_with_manifest_and_store(manifest_toml, client) + .await + .0 } async fn make_worker(client: MockClient) -> Worker { @@ -922,7 +935,7 @@ async fn pre_run_compact_publishes_runtime_progress_phases() { let session_before = worker.session_id(); let segment_before = worker.segment_id(); - worker.try_pre_run_compact().await; + worker.try_pre_run_compact().await.unwrap(); assert_eq!(worker.session_id(), session_before); assert_ne!(worker.segment_id(), segment_before); @@ -1083,7 +1096,7 @@ async fn pre_run_compact_failure_clears_runtime_progress() { let _ = drain(&mut rx); // Best-effort: returns Ok(()) even on failure and clears runtime progress. - worker.try_pre_run_compact().await; + worker.try_pre_run_compact().await.unwrap(); let events = drain(&mut rx); assert!(events.iter().any(|event| matches!( @@ -1104,8 +1117,11 @@ async fn pre_run_compact_failure_clears_runtime_progress() { #[tokio::test] async fn manual_compact_cancel_clears_progress_before_returning_idle() { - let worker = - make_worker_with_manifest(POST_RUN_MANIFEST_TOML, BlockingCompactClient::new()).await; + let (worker, observed_store) = + make_worker_with_manifest_and_store(POST_RUN_MANIFEST_TOML, BlockingCompactClient::new()) + .await; + let source_session = worker.session_id(); + let source_segment = worker.segment_id(); let runtime_tmp = tempfile::tempdir().unwrap(); let bash_output_dir = runtime_tmp.path().join("bash-output"); let (handle, shutdown_receiver) = @@ -1201,6 +1217,15 @@ async fn manual_compact_cancel_clears_progress_before_returning_idle() { break; } } + let completion_request_id = "subworker-completion-during-compact"; + handle + .send(Method::NotifyTracked { + notification_request_id: completion_request_id.into(), + message: "subworker completed".into(), + source: protocol::AuthenticatedInputSource::UntrustedWire, + }) + .await + .expect("send completion notification during compact"); let shutdown = protocol::WorkerCommandEnvelope::new(4); handle .send(Method::Shutdown { command: shutdown }) @@ -1230,6 +1255,18 @@ async fn manual_compact_cancel_clears_progress_before_returning_idle() { .await .expect("controller shutdown timeout") .expect("shutdown confirmation"); + + let persisted = observed_store + .read_all(source_session, source_segment) + .expect("read source Segment after shutdown"); + assert!(persisted.iter().any(|entry| { + matches!( + entry, + LogEntry::Extension { domain, payload, .. } + if domain == "worker.pending_activations.v1" + && payload.to_string().contains(completion_request_id) + ) + })); } #[tokio::test] diff --git a/crates/worker/tests/controller_test.rs b/crates/worker/tests/controller_test.rs index 72e587fee..2193945fa 100644 --- a/crates/worker/tests/controller_test.rs +++ b/crates/worker/tests/controller_test.rs @@ -1783,7 +1783,9 @@ async fn run_with_unresolved_segment_emits_alert_and_placeholder() { async fn notify_while_idle_auto_starts_turn_and_injects_system_message() { let client = MockClient::new(simple_text_events()); let client_for_assert = client.clone(); - let worker = make_worker(client).await; + let worker = make_worker(client) + .await + .with_notification_coalesce_delay(std::time::Duration::from_millis(20)); let handle = spawn_controller(worker).await; let mut rx = handle.subscribe(); @@ -1791,7 +1793,6 @@ async fn notify_while_idle_auto_starts_turn_and_injects_system_message() { .send(Method::Notify { notification_request_id: protocol::new_submission_request_id(), message: "turn finished".into(), - auto_run: true, }) .await .unwrap(); @@ -1883,10 +1884,12 @@ async fn notify_while_idle_auto_starts_turn_and_injects_system_message() { } #[tokio::test] -async fn notify_while_idle_with_auto_run_false_waits_for_explicit_run() { +async fn repeated_notify_while_idle_coalesces_and_auto_starts_one_turn() { let client = MockClient::new(simple_text_events()); let client_for_assert = client.clone(); - let worker = make_worker(client).await; + let worker = make_worker(client) + .await + .with_notification_coalesce_delay(std::time::Duration::from_millis(50)); let handle = spawn_controller(worker).await; let notification_request_id = protocol::new_submission_request_id(); @@ -1895,26 +1898,18 @@ async fn notify_while_idle_with_auto_run_false_waits_for_explicit_run() { .send(Method::Notify { notification_request_id: notification_request_id.clone(), message: "progress snapshot".into(), - auto_run: false, }) .await .unwrap(); } - - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Idle); - assert!( - client_for_assert.captured_requests().is_empty(), - "weak Notify must not stage RunForNotification while idle" - ); - handle - .send(Method::submit_text( - protocol::new_submission_request_id(), - "continue", - )) + .send(Method::Notify { + notification_request_id: "notify-coalesced-2".into(), + message: "second coalesced notification".into(), + }) .await .unwrap(); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); loop { if !client_for_assert.captured_requests().is_empty() { @@ -1922,30 +1917,26 @@ async fn notify_while_idle_with_auto_run_false_waits_for_explicit_run() { } assert!( tokio::time::Instant::now() < deadline, - "explicit run did not reach the mock LLM" + "coalescing deadline did not start a notification turn" ); tokio::time::sleep(std::time::Duration::from_millis(10)).await; } wait_for_status(&handle, WorkerStatus::Idle).await; let requests = client_for_assert.captured_requests(); + assert_eq!(requests.len(), 1, "coalesced notifications need one turn"); + let notifications = requests[0] + .items + .iter() + .filter_map(|item| item.as_text()) + .filter(|text| text.contains("[Notification]")) + .collect::>(); assert_eq!( - requests.len(), - 1, - "explicit run should drain the queued notification" - ); - let notify_in_request = requests[0].items.iter().any(|i| { - i.as_text() - .is_some_and(|t| t.contains("[Notification]") && t.contains("progress snapshot")) - }); - assert!( - notify_in_request, - "queued weak notification must be history-backed on the next explicit run; got items: {:?}", - requests[0] - .items - .iter() - .filter_map(|i| i.as_text()) - .collect::>() + notifications.len(), + 2, + "duplicate receipt must not duplicate content" ); + assert!(notifications[0].contains("progress snapshot")); + assert!(notifications[1].contains("second coalesced notification")); } #[tokio::test] @@ -2105,7 +2096,6 @@ async fn notify_while_running_does_not_emit_already_running_error() { .send(Method::Notify { notification_request_id: protocol::new_submission_request_id(), message: "ping".into(), - auto_run: true, }) .await .unwrap(); @@ -2137,7 +2127,7 @@ async fn notify_while_running_does_not_emit_already_running_error() { } #[tokio::test] -async fn weak_notify_while_running_is_deduped_and_survives_until_next_submit() { +async fn notify_while_running_is_deduped_and_survives_until_next_model_boundary() { let client = MockClient::sequential(vec![ MockResponse::Hang(Vec::new()), MockResponse::Complete(simple_text_events()), @@ -2160,7 +2150,6 @@ async fn weak_notify_while_running_is_deduped_and_survives_until_next_submit() { .send(Method::Notify { notification_request_id: notification_request_id.clone(), message: "durable weak notice".into(), - auto_run: false, }) .await .unwrap(); @@ -2586,11 +2575,11 @@ async fn drain_until bool>( } } -/// Pause mid-stream, then Resume: status round-trips Running → -/// Paused → Running → Idle, and the final history contains exactly -/// one user turn plus the assistant reply produced by the resume call. +/// Paused → Running → Idle. A notification accepted while Paused must not +/// auto-resume the Worker, and explicit Resume must inject it while preserving +/// the interrupted turn's history consistency. #[tokio::test] -async fn pause_then_resume_transitions_and_preserves_history_consistency() { +async fn pause_then_resume_preserves_notifications_and_history_consistency() { // Response 1: hang after opening a text block (no stop / completed), // so the Engine is parked inside the stream read and `cancel_rx` // races it cleanly on Method::Pause. @@ -2608,7 +2597,10 @@ async fn pause_then_resume_transitions_and_preserves_history_consistency() { }), ]); let client = MockClient::sequential(vec![hang, ok]); - let worker = make_worker(client).await; + let client_for_assert = client.clone(); + let worker = make_worker(client) + .await + .with_notification_coalesce_delay(std::time::Duration::from_millis(20)); let handle = spawn_controller(worker).await; let mut rx = handle.subscribe(); @@ -2654,6 +2646,25 @@ async fn pause_then_resume_transitions_and_preserves_history_consistency() { tokio::time::sleep(std::time::Duration::from_millis(50)).await; assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Paused); + handle + .send(Method::Notify { + notification_request_id: "notify-while-paused".into(), + message: "resume context".into(), + }) + .await + .unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(80)).await; + assert_eq!( + handle.shared_state.catalog_status(), + WorkerStatus::Paused, + "notification deadline must not auto-resume a paused Worker" + ); + assert_eq!( + client_for_assert.captured_requests().len(), + 1, + "paused notification must wait for an explicit resume" + ); + handle .send(Method::Resume { command: worker_command(&handle), @@ -2674,10 +2685,15 @@ async fn pause_then_resume_transitions_and_preserves_history_consistency() { tokio::time::sleep(std::time::Duration::from_millis(50)).await; assert_eq!(handle.shared_state.catalog_status(), WorkerStatus::Idle); + let requests = client_for_assert.captured_requests(); + assert_eq!(requests.len(), 2); + assert!(requests[1].items.iter().any(|item| { + item.as_text() + .is_some_and(|text| text.contains("[Notification]") && text.contains("resume context")) + })); - // History consistency: exactly [user "hello", assistant - // "resumed output"]. No artifacts from the aborted stream - // (partial text is not committed), no orphan tool_use. + // History consistency: the interrupted partial response is absent, while + // the queued notification is committed on the explicit resume. let history = history_from_sink(&handle); let roles: Vec<&str> = history .iter() @@ -2692,8 +2708,8 @@ async fn pause_then_resume_transitions_and_preserves_history_consistency() { .collect(); assert_eq!( roles, - vec!["user", "assistant"], - "history = user + assistant only; got {history:?}" + vec!["user", "system", "assistant"], + "history should retain user input, queued notification, and resumed output; got {history:?}" ); let assistant_text = history .iter() diff --git a/crates/workspace-api/examples/generate_auth_api_types.rs b/crates/workspace-api/examples/generate_auth_api_types.rs deleted file mode 100644 index b8506b217..000000000 --- a/crates/workspace-api/examples/generate_auth_api_types.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - print!("{}", workspace_api::auth_api_typescript()); -} diff --git a/crates/workspace-api/examples/generate_companion_api_types.rs b/crates/workspace-api/examples/generate_companion_api_types.rs deleted file mode 100644 index 8c27410d1..000000000 --- a/crates/workspace-api/examples/generate_companion_api_types.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - print!("{}", workspace_api::companion_api_typescript()); -} diff --git a/crates/workspace-api/examples/generate_memory_api_types.rs b/crates/workspace-api/examples/generate_memory_api_types.rs deleted file mode 100644 index 87a9de912..000000000 --- a/crates/workspace-api/examples/generate_memory_api_types.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - print!("{}", workspace_api::memory_api_typescript()); -} diff --git a/crates/workspace-api/examples/generate_repository_access_types.rs b/crates/workspace-api/examples/generate_repository_access_types.rs deleted file mode 100644 index 48cc26657..000000000 --- a/crates/workspace-api/examples/generate_repository_access_types.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - print!("{}", workspace_api::repository_access_api_typescript()); -} diff --git a/crates/workspace-api/examples/generate_skill_api_types.rs b/crates/workspace-api/examples/generate_skill_api_types.rs deleted file mode 100644 index 4fa47ded2..000000000 --- a/crates/workspace-api/examples/generate_skill_api_types.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - print!("{}", workspace_api::skill_api_typescript()); -} diff --git a/crates/workspace-api/examples/generate_typescript.rs b/crates/workspace-api/examples/generate_typescript.rs deleted file mode 100644 index 9b4e949d2..000000000 --- a/crates/workspace-api/examples/generate_typescript.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - print!("{}", workspace_api::catalog_typescript()); -} diff --git a/crates/workspace-api/examples/generate_workdir_api_types.rs b/crates/workspace-api/examples/generate_workdir_api_types.rs deleted file mode 100644 index 53371add2..000000000 --- a/crates/workspace-api/examples/generate_workdir_api_types.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - print!("{}", workspace_api::workdir_api_typescript()); -} diff --git a/crates/workspace-api/examples/generate_worker_launch_api_types.rs b/crates/workspace-api/examples/generate_worker_launch_api_types.rs deleted file mode 100644 index ffcf6248a..000000000 --- a/crates/workspace-api/examples/generate_worker_launch_api_types.rs +++ /dev/null @@ -1,3 +0,0 @@ -fn main() { - print!("{}", workspace_api::worker_launch_api_typescript()); -} diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index e4e589409..39b41e9c4 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -12,7 +12,7 @@ path = "src/main.rs" [features] default = ["typescript"] -typescript = ["dep:ts-rs", "workspace-api/typescript"] +typescript = ["dep:ts-rs", "server-api/typescript"] [dependencies] async-trait.workspace = true @@ -23,6 +23,8 @@ config-source.workspace = true flow = { path = "../flow" } manifest.workspace = true protocol = { workspace = true } +runtime-api.workspace = true +server-api.workspace = true project-record.workspace = true reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "native-tls"] } ring.workspace = true @@ -40,7 +42,6 @@ tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread" tower.workspace = true tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] } worker.workspace = true -workspace-api.workspace = true workdir = { workspace = true, features = ["http-client"] } worker-runtime.workspace = true toml.workspace = true diff --git a/crates/workspace-server/src/auth.rs b/crates/workspace-server/src/auth.rs index a135f5e01..aa202f2de 100644 --- a/crates/workspace-server/src/auth.rs +++ b/crates/workspace-server/src/auth.rs @@ -1,8 +1,8 @@ use axum::http::{HeaderMap, header}; use chrono::{DateTime, Duration, Utc}; +pub use server_api::{ActorAuthMethod, AuthPublicConfig, AuthenticatedUser, RequestActor}; use sha2::{Digest, Sha256}; use uuid::Uuid; -pub use workspace_api::{ActorAuthMethod, AuthPublicConfig, AuthenticatedUser, RequestActor}; use crate::{Error, Result, store::ControlPlaneStore}; diff --git a/crates/workspace-server/src/companion.rs b/crates/workspace-server/src/companion.rs index 08271a74b..f58859d2d 100644 --- a/crates/workspace-server/src/companion.rs +++ b/crates/workspace-server/src/companion.rs @@ -1,9 +1,9 @@ -use workspace_api::{ +use server_api::{ CompanionLifecycleState, CompanionMessageDisposition, CompanionTransportSummary, Diagnostic, DiagnosticSeverity, }; -pub use workspace_api::{ +pub use server_api::{ CompanionCancelRequest, CompanionMessageRequest, CompanionMessageResponse, CompanionStatusResponse, CompanionTranscriptProjection, }; diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 6cd702387..fd9c19172 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -15,7 +15,6 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ - error::Error as _, future::Future, io::Read as _, net::{IpAddr, SocketAddr, ToSocketAddrs}, @@ -47,12 +46,7 @@ use worker_runtime::http_server::{ RUNTIME_PING_PERMISSION, RUNTIME_WORKSPACE_SCOPE_HEADER, RuntimeHttpConfigBundleAvailabilityResponse, RuntimeHttpConfigBundleSyncRequest, RuntimeHttpErrorResponse, RuntimeHttpPingResponse, RuntimeHttpRepositoryAccessResponse, - RuntimeHttpSummaryResponse, RuntimeHttpUploadedFileDeleteResponse, - RuntimeHttpUploadedFileResponse, RuntimeHttpWorkerCompletionsRequest, - RuntimeHttpWorkerCompletionsResponse, RuntimeHttpWorkerDeleteResponse, - RuntimeHttpWorkerInputResponse, RuntimeHttpWorkerLifecycleRequest, - RuntimeHttpWorkerLifecycleResponse, RuntimeHttpWorkerResponse, - RuntimeHttpWorkerWorkspaceApiRequest, RuntimeHttpWorkersResponse, + RuntimeHttpUploadedFileDeleteResponse, RuntimeHttpUploadedFileResponse, RuntimeHttpWorkingDirectoriesResponse, RuntimeHttpWorkingDirectoryResponse, RuntimeHttpWorkspacePromptProjectionRequest, RuntimeHttpWorkspacePromptProjectionResponse, }; @@ -281,12 +275,12 @@ pub struct WorkerSummary { pub diagnostics: Vec, } -impl From for workspace_api::Diagnostic { +impl From for server_api::Diagnostic { fn from(diagnostic: RuntimeDiagnostic) -> Self { let severity = match diagnostic.severity { - DiagnosticSeverity::Info => workspace_api::DiagnosticSeverity::Info, - DiagnosticSeverity::Warning => workspace_api::DiagnosticSeverity::Warning, - DiagnosticSeverity::Error => workspace_api::DiagnosticSeverity::Error, + DiagnosticSeverity::Info => server_api::DiagnosticSeverity::Info, + DiagnosticSeverity::Warning => server_api::DiagnosticSeverity::Warning, + DiagnosticSeverity::Error => server_api::DiagnosticSeverity::Error, }; Self { code: diagnostic.code, @@ -296,21 +290,21 @@ impl From for workspace_api::Diagnostic { } } -impl From for workspace_api::RuntimeSourceSummary { +impl From for server_api::RuntimeSourceSummary { fn from(source: RuntimeSourceSummary) -> Self { let kind = match source.kind { RuntimeSourceKind::EmbeddedWorkerRuntime => { - workspace_api::RuntimeSourceKind::EmbeddedWorkerRuntime + server_api::RuntimeSourceKind::EmbeddedWorkerRuntime } - RuntimeSourceKind::RemoteHttp => workspace_api::RuntimeSourceKind::RemoteHttp, + RuntimeSourceKind::RemoteHttp => server_api::RuntimeSourceKind::RemoteHttp, }; let status = match source.status { - RuntimeSourceStatus::Active => workspace_api::RuntimeSourceStatus::Active, - RuntimeSourceStatus::Reserved => workspace_api::RuntimeSourceStatus::Reserved, + RuntimeSourceStatus::Active => server_api::RuntimeSourceStatus::Active, + RuntimeSourceStatus::Reserved => server_api::RuntimeSourceStatus::Reserved, }; let identity_authority = match source.identity_authority { RuntimeIdentityAuthority::RuntimeRegistryProjection => { - workspace_api::RuntimeIdentityAuthority::RuntimeRegistryProjection + server_api::RuntimeIdentityAuthority::RuntimeRegistryProjection } }; Self { @@ -322,7 +316,7 @@ impl From for workspace_api::RuntimeSourceSummary { } } -impl From for workspace_api::RuntimeSummary { +impl From for server_api::RuntimeSummary { fn from(runtime: RuntimeSummary) -> Self { Self { runtime_id: runtime.runtime_id, @@ -342,9 +336,9 @@ impl From for workspace_api::RuntimeSummary { pub(crate) fn workspace_worker_summary( summary: WorkerSummary, resource_key: String, - working_directory: Option, -) -> workspace_api::WorkerSummary { - workspace_api::WorkerSummary { + working_directory: Option, +) -> server_api::WorkerSummary { + server_api::WorkerSummary { runtime_id: summary.worker.runtime_id, worker_id: summary.worker.worker_id, resource_key, @@ -354,7 +348,7 @@ pub(crate) fn workspace_worker_summary( profile: summary.profile, singleton_key: summary.singleton_key, tags: summary.tags, - workspace: workspace_api::WorkerWorkspaceSummary { + workspace: server_api::WorkerWorkspaceSummary { visibility: summary.workspace.visibility, identity: summary.workspace.identity, workspace_id: summary.workspace.workspace_id, @@ -364,11 +358,11 @@ pub(crate) fn workspace_worker_summary( last_seen_at: summary.last_seen_at, pinned: summary.pinned, retention_state: summary.retention_state, - implementation: workspace_api::WorkerImplementationSummary { + implementation: server_api::WorkerImplementationSummary { kind: summary.implementation.kind, display_hint: summary.implementation.display_hint, }, - capabilities: workspace_api::WorkerCapabilitySummary { + capabilities: server_api::WorkerCapabilitySummary { can_stop: summary.capabilities.can_stop, can_spawn_followup: summary.capabilities.can_spawn_followup, }, @@ -378,8 +372,9 @@ pub(crate) fn workspace_worker_summary( } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] pub struct WorkerRestoreResult { - pub state: WorkerOperationState, + pub state: server_api::WorkerRestoreState, #[serde(skip_serializing_if = "Option::is_none")] pub worker: Option, pub diagnostics: Vec, @@ -618,7 +613,7 @@ pub enum WorkerOperationState { Rejected, } -impl From for workspace_api::WorkerOperationState { +impl From for server_api::WorkerOperationState { fn from(state: WorkerOperationState) -> Self { match state { WorkerOperationState::Accepted => Self::Accepted, @@ -882,7 +877,7 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { fn restore_worker(&self, worker_id: &str) -> WorkerRestoreResult { WorkerRestoreResult { - state: WorkerOperationState::Unsupported, + state: server_api::WorkerRestoreState::Rejected, worker: None, diagnostics: vec![diagnostic( "worker_restore_unsupported", @@ -1137,6 +1132,17 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { )) } + fn worker_session( + &self, + _workspace_id: &str, + _worker_ref: &EmbeddedWorkerRef, + ) -> Result { + Ok(runtime_api::WorkerSessionAvailability::Unavailable { + reason: runtime_api::WorkerSessionUnavailableReason::StorageUnavailable, + message: "retained session storage is unavailable".to_string(), + }) + } + fn observation_source( &self, _worker_id: &str, @@ -1927,6 +1933,29 @@ impl RuntimeRegistry { }) } + pub fn worker_session( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + ) -> Result { + validate_backend_identifier("runtime_id", &worker.runtime_id)?; + validate_backend_identifier("worker_id", &worker.worker_id)?; + let runtime = self.runtime(&worker.runtime_id)?; + let worker_ref = + EmbeddedWorkerRef::new(EmbeddedWorkerId::parse(&worker.worker_id).ok_or_else( + || RuntimeRegistryError::UnknownWorker { + worker: worker.clone(), + }, + )?); + runtime + .worker_session(workspace_id, &worker_ref) + .map_err(|message| RuntimeRegistryError::RuntimeOperationFailed { + runtime_id: worker.runtime_id.clone(), + code: "worker_session_observation_failed".to_string(), + message, + }) + } + pub fn observation_source( &self, worker: &RuntimeWorkerRef, @@ -2336,7 +2365,7 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { fn restore_worker(&self, worker_id: &str) -> WorkerRestoreResult { let Some(worker_ref) = self.worker_ref(worker_id) else { return WorkerRestoreResult { - state: WorkerOperationState::Rejected, + state: server_api::WorkerRestoreState::Rejected, worker: None, diagnostics: vec![diagnostic( "embedded_worker_id_invalid", @@ -2345,14 +2374,22 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { )], }; }; - match self.runtime.restore_worker(&worker_ref) { - Ok(detail) => WorkerRestoreResult { - state: WorkerOperationState::Accepted, - worker: Some(self.map_worker_detail(detail)), - diagnostics: Vec::new(), - }, + match self.runtime.restore_worker_operation(&worker_ref) { + Ok(result) => { + let diagnostics = match (result.reason_code, result.message) { + (Some(code), Some(message)) => { + vec![diagnostic(code, DiagnosticSeverity::Warning, message)] + } + _ => Vec::new(), + }; + WorkerRestoreResult { + state: result.state, + worker: result.worker.map(|detail| self.map_worker_detail(detail)), + diagnostics, + } + } Err(err) => WorkerRestoreResult { - state: WorkerOperationState::Rejected, + state: server_api::WorkerRestoreState::ReconciliationRequired, worker: None, diagnostics: vec![embedded_runtime_diagnostic(&err)], }, @@ -2770,6 +2807,17 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { .map_err(|error| error.to_string()) } + fn worker_session( + &self, + workspace_id: &str, + worker_ref: &EmbeddedWorkerRef, + ) -> Result { + let scope = RuntimeWorkspaceScope::new(workspace_id, "embedded-backend"); + self.runtime + .worker_session_scoped(&scope, worker_ref) + .map_err(|error| error.to_string()) + } + fn observation_source( &self, worker_id: &str, @@ -3401,49 +3449,7 @@ pub struct RemoteWorkerRuntime { resource_broker: BackendResourceBroker, http: BlockingHttpClient, async_http: AsyncHttpClient, -} - -fn remote_runtime_ping_transport_failure(error: reqwest::Error) -> RuntimePingFailure { - if error.is_timeout() { - return RuntimePingFailure::new( - RuntimePingFailureKind::Timeout, - "runtime_ping_timeout", - "Runtime ping timed out", - ); - } - let mut source = error.source(); - let mut tls_error = false; - while let Some(current) = source { - let message = current.to_string().to_ascii_lowercase(); - if message.contains("tls") - || message.contains("certificate") - || message.contains("unknownissuer") - || message.contains("handshake") - { - tls_error = true; - break; - } - source = current.source(); - } - if tls_error { - return RuntimePingFailure::new( - RuntimePingFailureKind::TlsOrTransport, - "runtime_ping_tls_failed", - "Runtime TLS connection failed", - ); - } - if error.is_connect() { - return RuntimePingFailure::new( - RuntimePingFailureKind::NetworkUnreachable, - "runtime_ping_network_unreachable", - "Runtime could not be reached", - ); - } - RuntimePingFailure::new( - RuntimePingFailureKind::TlsOrTransport, - "runtime_ping_transport_failed", - "Runtime ping transport failed", - ) + request_timeout: Duration, } fn workspace_runtime_operation(method: &str, path_and_query: &str) -> &'static str { @@ -3509,6 +3515,54 @@ fn worker_id_from_remote_path(path_and_query: &str) -> Option { (!worker_id.is_empty()).then(|| worker_id.to_string()) } +#[derive(Clone)] +struct RemoteRuntimeApiAuthorizer { + workspace_id: String, + bearer_token: Option, + workspace_authorization: Option, +} + +impl runtime_api::client_support::RequestAuthorizer for RemoteRuntimeApiAuthorizer { + fn authorize( + &self, + request: runtime_api::client_support::AuthorizerRequest<'_>, + ) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + headers.insert( + "x-yoi-workspace-id", + self.workspace_id + .parse() + .map_err(|_| runtime_api::client_support::AuthorizationError::new())?, + ); + let authorization = if let Some(authorizer) = &self.workspace_authorization { + let operation = + workspace_runtime_operation(request.method.as_str(), request.path_and_query); + let worker_id = worker_id_from_remote_path(request.path_and_query); + let token = authorizer + .issue( + request.method.as_str(), + request.path_and_query, + operation, + worker_id.as_deref(), + request.body, + ) + .map_err(|_| runtime_api::client_support::AuthorizationError::new())?; + format!("Bearer {token}") + } else if let Some(token) = self.bearer_token.as_deref() { + format!("Bearer {token}") + } else { + return Ok(headers); + }; + headers.insert( + AUTHORIZATION, + authorization + .parse() + .map_err(|_| runtime_api::client_support::AuthorizationError::new())?, + ); + Ok(headers) + } +} + impl RemoteWorkerRuntime { pub fn new( config: RemoteRuntimeConfig, @@ -3580,6 +3634,7 @@ impl RemoteWorkerRuntime { resource_broker: BackendResourceBroker::default(), http, async_http, + request_timeout: timeout, }) } @@ -3613,6 +3668,72 @@ impl RemoteWorkerRuntime { .await } + fn runtime_api_client( + &self, + timeout: Duration, + response_body_limit: usize, + ) -> Result, RuntimeDiagnostic> { + runtime_api::RuntimeApiClient::builder(&self.base_url) + .map_err(|error| { + diagnostic( + "remote_runtime_client_build_failed", + DiagnosticSeverity::Error, + error.to_string(), + ) + })? + .client(self.async_http.clone()) + .authorizer(RemoteRuntimeApiAuthorizer { + workspace_id: self.workspace_id.clone(), + bearer_token: self.bearer_token.clone(), + workspace_authorization: self.workspace_authorization.clone(), + }) + .request_timeout(timeout) + .response_body_limit(response_body_limit) + .build() + .map_err(|error| { + diagnostic( + "remote_runtime_client_build_failed", + DiagnosticSeverity::Error, + error.to_string(), + ) + }) + } + + fn run_runtime_api( + &self, + timeout: Duration, + response_body_limit: usize, + operation: F, + ) -> Result + where + T: Send + 'static, + F: FnOnce(runtime_api::RuntimeApiClient) -> Fut + + Send + + 'static, + Fut: std::future::Future< + Output = Result< + T, + runtime_api::client_support::ClientError, + >, + > + Send + + 'static, + { + let client = self.runtime_api_client(timeout, response_body_limit)?; + let runtime_id = self.runtime_id.clone(); + run_blocking_http(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| { + runtime_api::client_support::ClientError::failure( + runtime_api::client_support::ClientFailure::Transport, + ) + })? + .block_on(operation(client)) + }) + .map_err(|error| runtime_api_diagnostic(&runtime_id, error)) + } + fn endpoint(&self, path: &str) -> String { format!("{}{}", self.base_url, path) } @@ -3743,88 +3864,6 @@ impl RemoteWorkerRuntime { self.send_json(path, "DELETE", &[], self.http.delete(self.endpoint(path))) } - fn ping_http(&self) -> Result { - const PATH: &str = "/v1/ping"; - let workspace_id = self.workspace_id.clone(); - let bearer_token = self.bearer_token.clone(); - let capability_token = match &self.workspace_authorization { - Some(authorization) => Some( - authorization - .issue("GET", PATH, RUNTIME_PING_PERMISSION, None, &[]) - .map_err(|diagnostic| { - RuntimePingFailure::new( - RuntimePingFailureKind::Authentication, - diagnostic.code, - diagnostic.message, - ) - })?, - ), - None => None, - }; - let request = self - .http - .get(self.endpoint(PATH)) - .header(RUNTIME_WORKSPACE_SCOPE_HEADER, &workspace_id); - run_blocking_http(move || { - let request = match capability_token.as_deref().or(bearer_token.as_deref()) { - Some(token) => request.header(AUTHORIZATION, format!("Bearer {token}")), - None => request, - }; - let response = request - .send() - .map_err(remote_runtime_ping_transport_failure)?; - match response.status() { - StatusCode::UNAUTHORIZED => { - return Err(RuntimePingFailure::new( - RuntimePingFailureKind::Authentication, - "runtime_ping_authentication_failed", - "Runtime rejected the connection-test credential", - )); - } - StatusCode::FORBIDDEN => { - return Err(RuntimePingFailure::new( - RuntimePingFailureKind::Authorization, - "runtime_ping_authorization_failed", - "Runtime rejected the connection-test scope or permission", - )); - } - status if !status.is_success() => { - return Err(RuntimePingFailure::new( - RuntimePingFailureKind::TlsOrTransport, - "runtime_ping_http_failed", - "Runtime ping returned an unsuccessful HTTP response", - )); - } - _ => {} - } - let mut body = Vec::new(); - response - .take((MAX_RUNTIME_PING_RESPONSE_BYTES + 1) as u64) - .read_to_end(&mut body) - .map_err(|_| { - RuntimePingFailure::new( - RuntimePingFailureKind::TlsOrTransport, - "runtime_ping_response_read_failed", - "Runtime ping response could not be read", - ) - })?; - if body.len() > MAX_RUNTIME_PING_RESPONSE_BYTES { - return Err(RuntimePingFailure::new( - RuntimePingFailureKind::MalformedResponse, - "runtime_ping_response_too_large", - "Runtime ping response exceeded the allowed size", - )); - } - serde_json::from_slice::(&body).map_err(|_| { - RuntimePingFailure::new( - RuntimePingFailureKind::MalformedResponse, - "runtime_ping_malformed_response", - "Runtime ping returned an unrecognized response", - ) - }) - }) - } - fn send_json( &self, path: &str, @@ -3993,7 +4032,7 @@ impl RemoteWorkerRuntime { fn lifecycle_result_from_response( &self, worker_id: &str, - response: RuntimeHttpWorkerLifecycleResponse, + response: worker_runtime::catalog::WorkerLifecycleAck, ) -> WorkerLifecycleResult { WorkerLifecycleResult { state: WorkerOperationState::Accepted, @@ -4003,7 +4042,7 @@ impl RemoteWorkerRuntime { DiagnosticSeverity::Info, format!( "Remote Runtime acknowledged lifecycle operation for '{worker_id}' with status {}", - embedded_worker_status_label(response.ack.status) + embedded_worker_status_label(response.status) ), )], } @@ -4016,24 +4055,33 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { } fn runtime_summary(&self, limit: usize) -> RuntimeSummary { - match self.get_json::("/v1/runtime") { + match self + .run_runtime_api( + self.request_timeout, + MAX_REMOTE_RUNTIME_RESPONSE_BYTES, + |client| async move { client.runtime_summary().await }, + ) + .and_then(|value| { + runtime_contract_convert::<_, worker_runtime::management::RuntimeSummary>( + value.runtime, + ) + }) { Ok(response) => RuntimeSummary { runtime_id: self.runtime_id.clone(), label: response - .runtime .display_name .unwrap_or_else(|| self.display_name.clone()), kind: "remote_worker_runtime".to_string(), - status: embedded_runtime_status_label(response.runtime.status).to_string(), + status: embedded_runtime_status_label(response.status).to_string(), source: RuntimeSourceSummary::remote_http(), host_ids: if limit == 0 { Vec::new() } else { vec![self.host_id.clone()] }, - worker_creation_available: response.runtime.worker_creation_available, - os: response.runtime.os, - arch: response.runtime.arch, + worker_creation_available: response.worker_creation_available, + os: response.os, + arch: response.arch, diagnostics: Vec::new(), }, Err(diagnostic) => RuntimeSummary { @@ -4056,7 +4104,50 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { } fn ping(&self) -> Result { - self.ping_http() + let workspace_id = self.workspace_id.clone(); + self.run_runtime_api( + self.request_timeout, + MAX_RUNTIME_PING_RESPONSE_BYTES, + move |client| async move { client.ping(workspace_id).await }, + ) + .and_then(runtime_contract_convert) + .map_err(|diagnostic| { + let kind = if diagnostic.code.contains("auth") { + RuntimePingFailureKind::Authentication + } else if diagnostic.code.contains("network_unreachable") { + RuntimePingFailureKind::NetworkUnreachable + } else if diagnostic.code.contains("timeout") { + RuntimePingFailureKind::Timeout + } else if diagnostic.code.contains("invalid_response") + || diagnostic.code.contains("response_too_large") + { + RuntimePingFailureKind::MalformedResponse + } else { + RuntimePingFailureKind::TlsOrTransport + }; + let (code, message) = match kind { + RuntimePingFailureKind::Authentication => ( + "runtime_ping_authentication_failed", + "Runtime rejected ping authentication", + ), + RuntimePingFailureKind::NetworkUnreachable => ( + "runtime_ping_network_unreachable", + "Runtime could not be reached", + ), + RuntimePingFailureKind::Timeout => { + ("runtime_ping_timeout", "Runtime ping timed out") + } + RuntimePingFailureKind::MalformedResponse => ( + "runtime_ping_malformed_response", + "Runtime ping returned a malformed response", + ), + _ => ( + "runtime_ping_transport_failed", + "Runtime ping transport failed", + ), + }; + RuntimePingFailure::new(kind, code, message) + }) } fn activate_workspace_authorization(&self, binding: crate::store::WorkspaceRuntimeBinding) { @@ -4114,10 +4205,23 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { if limit == 0 { return RuntimeList::new(Vec::new(), Vec::new()); } - match self.get_json::("/v1/workers") { + match self + .run_runtime_api( + self.request_timeout, + MAX_REMOTE_RUNTIME_RESPONSE_BYTES, + |client| async move { + client + .list_workers(runtime_api::WorkerListQuery::default()) + .await + }, + ) + .and_then(|value| { + runtime_contract_convert::<_, Vec>( + value.workers, + ) + }) { Ok(response) => RuntimeList::new( response - .workers .into_iter() .take(limit) .map(|worker| self.map_worker_summary(worker)) @@ -4132,10 +4236,25 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { if limit == 0 { return RuntimeList::new(Vec::new(), Vec::new()); } - match self.get_json::("/v1/workers?status=stopped") { + match self + .run_runtime_api( + self.request_timeout, + MAX_REMOTE_RUNTIME_RESPONSE_BYTES, + |client| async move { + client + .list_workers(runtime_api::WorkerListQuery { + status: Some(runtime_api::WorkerStatusFilter::Stopped), + }) + .await + }, + ) + .and_then(|value| { + runtime_contract_convert::<_, Vec>( + value.workers, + ) + }) { Ok(response) => RuntimeList::new( response - .workers .into_iter() .take(limit) .map(|worker| self.map_worker_summary(worker)) @@ -4147,9 +4266,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { } fn worker(&self, worker_id: &str) -> WorkerLookupResult { - match self.get_json::(&format!("/v1/workers/{worker_id}")) { + let worker_id_owned = worker_id.to_string(); + match self + .run_runtime_api( + self.request_timeout, + MAX_REMOTE_RUNTIME_RESPONSE_BYTES, + move |client| async move { client.get_worker(worker_id_owned).await }, + ) + .and_then(|value| { + runtime_contract_convert::<_, worker_runtime::catalog::WorkerDetail>(value.worker) + }) { Ok(response) => WorkerLookupResult { - worker: Some(self.map_worker_detail(response.worker)), + worker: Some(self.map_worker_detail(response)), diagnostics: Vec::new(), }, Err(diagnostic) if diagnostic.code == "worker_not_found" => WorkerLookupResult { @@ -4164,17 +4292,54 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { } fn restore_worker(&self, worker_id: &str) -> WorkerRestoreResult { - match self.post_json::<_, RuntimeHttpWorkerResponse>( - &format!("/v1/workers/{worker_id}/restore"), - &serde_json::json!({}), - ) { - Ok(response) => WorkerRestoreResult { - state: WorkerOperationState::Accepted, - worker: Some(self.map_worker_detail(response.worker)), - diagnostics: Vec::new(), - }, + let worker_id_owned = worker_id.to_string(); + match self + .run_runtime_api( + self.request_timeout, + MAX_REMOTE_RUNTIME_RESPONSE_BYTES, + move |client| async move { + client + .restore_worker(worker_id_owned, runtime_api::EmptyObjectRequest::default()) + .await + }, + ) + .and_then(|value| { + let worker = value + .worker + .map(runtime_contract_convert::<_, worker_runtime::catalog::WorkerDetail>) + .transpose()?; + Ok((value.state, worker, value.reason_code, value.message)) + }) { + Ok((state, worker, reason_code, message)) => { + let diagnostics = match (reason_code, message) { + (Some(code), Some(message)) => { + vec![diagnostic(code, DiagnosticSeverity::Warning, message)] + } + _ => Vec::new(), + }; + WorkerRestoreResult { + state: match state { + runtime_api::WorkerRestoreState::Accepted => { + server_api::WorkerRestoreState::Accepted + } + runtime_api::WorkerRestoreState::Rejected => { + server_api::WorkerRestoreState::Rejected + } + runtime_api::WorkerRestoreState::RolledBack => { + server_api::WorkerRestoreState::RolledBack + } + runtime_api::WorkerRestoreState::ReconciliationRequired => { + server_api::WorkerRestoreState::ReconciliationRequired + } + }, + worker: worker.map(|worker| self.map_worker_detail(worker)), + diagnostics, + } + } Err(diagnostic) => WorkerRestoreResult { - state: WorkerOperationState::Rejected, + // A transport/protocol failure cannot prove that the Runtime + // rejected before side effects. Preserve uncertainty. + state: server_api::WorkerRestoreState::ReconciliationRequired, worker: None, diagnostics: vec![diagnostic], }, @@ -4186,13 +4351,33 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { worker_id: &str, workspace_api: WorkspaceApiRef, ) -> WorkerWorkspaceApiResult { - match self.post_json::<_, RuntimeHttpWorkerResponse>( - &format!("/v1/workers/{worker_id}/workspace-api"), - &RuntimeHttpWorkerWorkspaceApiRequest { workspace_api }, - ) { + let request = match runtime_contract_convert(workspace_api) { + Ok(workspace_api) => runtime_api::WorkerWorkspaceApiRequest { workspace_api }, + Err(diagnostic) => { + return WorkerWorkspaceApiResult { + state: WorkerOperationState::Rejected, + worker: None, + diagnostics: vec![diagnostic], + }; + } + }; + let worker_id = worker_id.to_string(); + match self + .run_runtime_api( + self.request_timeout, + MAX_REMOTE_RUNTIME_RESPONSE_BYTES, + move |client| async move { + client + .replace_worker_workspace_api(worker_id, request) + .await + }, + ) + .and_then(|value| { + runtime_contract_convert::<_, worker_runtime::catalog::WorkerDetail>(value.worker) + }) { Ok(response) => WorkerWorkspaceApiResult { state: WorkerOperationState::Accepted, - worker: Some(self.map_worker_detail(response.worker)), + worker: Some(self.map_worker_detail(response)), diagnostics: Vec::new(), }, Err(diagnostic) => WorkerWorkspaceApiResult { @@ -4389,34 +4574,29 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { workspace_api: Some(workspace_api), memory_settings: request.resolved_memory_settings.clone(), }; - let create_body = match serde_json::to_vec(&create) { - Ok(body) => body, - Err(error) => { + let create = match runtime_contract_convert(create) { + Ok(create) => create, + Err(diagnostic) => { return WorkerSpawnResult { state: WorkerOperationState::Rejected, worker: None, acceptance_evidence: Vec::new(), - diagnostics: vec![diagnostic( - "remote_runtime_request_encode_failed", - DiagnosticSeverity::Error, - error.to_string(), - )], + diagnostics: vec![diagnostic], }; } }; - match self.send_json::( - "/v1/workers", - "POST", - &create_body, - self.http - .post(self.endpoint("/v1/workers")) - .timeout(REMOTE_WORKER_CREATE_TIMEOUT) - .header(CONTENT_TYPE, "application/json") - .body(create_body.clone()), - ) { + match self + .run_runtime_api( + REMOTE_WORKER_CREATE_TIMEOUT, + MAX_REMOTE_RUNTIME_RESPONSE_BYTES, + move |client| async move { client.create_worker(create).await }, + ) + .and_then(|value| { + runtime_contract_convert::<_, worker_runtime::catalog::WorkerDetail>(value.worker) + }) { Ok(response) => WorkerSpawnResult { state: WorkerOperationState::Accepted, - worker: Some(self.map_worker_detail(response.worker)), + worker: Some(self.map_worker_detail(response)), acceptance_evidence: vec![WorkerSpawnAcceptanceEvidence { kind: "remote_runtime_worker_created".to_string(), detail: "worker-runtime REST create endpoint accepted the Worker".to_string(), @@ -4484,13 +4664,21 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { worker_id: &str, request: WorkerLifecycleRequest, ) -> WorkerLifecycleResult { - let body = RuntimeHttpWorkerLifecycleRequest { + let body = runtime_api::WorkerLifecycleRequest { reason: request.reason, }; - match self.post_json::<_, RuntimeHttpWorkerLifecycleResponse>( - &format!("/v1/workers/{worker_id}/stop"), - &body, - ) { + let worker_id_owned = worker_id.to_string(); + match self + .run_runtime_api( + self.request_timeout, + MAX_REMOTE_RUNTIME_RESPONSE_BYTES, + move |client| async move { client.stop_worker(worker_id_owned, body).await }, + ) + .and_then(|value| { + runtime_contract_convert::<_, worker_runtime::catalog::WorkerLifecycleAck>( + value.ack, + ) + }) { Ok(response) => self.lifecycle_result_from_response(worker_id, response), Err(diagnostic) => remote_lifecycle_rejected(&self.runtime_id, worker_id, diagnostic), } @@ -4501,29 +4689,46 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { worker_id: &str, request: WorkerLifecycleRequest, ) -> WorkerLifecycleResult { - let body = RuntimeHttpWorkerLifecycleRequest { + let body = runtime_api::WorkerLifecycleRequest { reason: request.reason, }; - match self.post_json::<_, RuntimeHttpWorkerLifecycleResponse>( - &format!("/v1/workers/{worker_id}/cancel"), - &body, - ) { + let worker_id_owned = worker_id.to_string(); + match self + .run_runtime_api( + self.request_timeout, + MAX_REMOTE_RUNTIME_RESPONSE_BYTES, + move |client| async move { client.cancel_worker(worker_id_owned, body).await }, + ) + .and_then(|value| { + runtime_contract_convert::<_, worker_runtime::catalog::WorkerLifecycleAck>( + value.ack, + ) + }) { Ok(response) => self.lifecycle_result_from_response(worker_id, response), Err(diagnostic) => remote_lifecycle_rejected(&self.runtime_id, worker_id, diagnostic), } } fn delete_worker(&self, worker_id: &str) -> WorkerDeleteResult { + let worker_id_owned = worker_id.to_string(); match self - .delete_json::(&format!("/v1/workers/{worker_id}")) - { + .run_runtime_api( + self.request_timeout, + MAX_REMOTE_RUNTIME_RESPONSE_BYTES, + move |client| async move { client.delete_worker(worker_id_owned).await }, + ) + .and_then(|value| { + runtime_contract_convert::<_, worker_runtime::management::WorkerDeleteResult>( + value.worker, + ) + }) { Ok(response) => WorkerDeleteResult { state: WorkerOperationState::Accepted, worker: RuntimeWorkerRef::new( self.runtime_id.clone(), - response.worker.worker_id.to_string(), + response.worker_id.to_string(), ), - deleted: response.worker.deleted, + deleted: response.deleted, diagnostics: Vec::new(), }, Err(diagnostic) => WorkerDeleteResult { @@ -4539,9 +4744,13 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { &self, worker_id: &str, ) -> Result { - self.get_json::(&format!( - "/v1/workers/{worker_id}/retention/inventory" - )) + let worker_id = worker_id.to_string(); + self.run_runtime_api( + self.request_timeout, + MAX_REMOTE_RUNTIME_RESPONSE_BYTES, + move |client| async move { client.retention_inventory(worker_id).await }, + ) + .and_then(runtime_contract_convert) .map_err(|diagnostic| diagnostic.message) } @@ -4550,9 +4759,30 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { request: WorkerRetentionExecutionRequest, ) -> Result { let worker_id = request.worker_id.to_string(); - self.post_json::<_, WorkerRetentionExecutionResult>( - &format!("/v1/workers/{worker_id}/retention/execute"), - &request, + let request = runtime_contract_convert(request).map_err(|diagnostic| diagnostic.message)?; + let worker_id = worker_id.to_string(); + self.run_runtime_api( + self.request_timeout, + MAX_REMOTE_RUNTIME_RESPONSE_BYTES, + move |client| async move { client.execute_retention(worker_id, request).await }, + ) + .and_then(runtime_contract_convert) + .map_err(|diagnostic| diagnostic.message) + } + + fn worker_session( + &self, + workspace_id: &str, + worker_ref: &EmbeddedWorkerRef, + ) -> Result { + let worker_id = worker_ref.worker_id.to_string(); + let request = runtime_api::WorkerSessionRequest { + workspace_id: workspace_id.to_string(), + }; + self.run_runtime_api( + self.request_timeout, + MAX_REMOTE_RUNTIME_RESPONSE_BYTES, + move |client| async move { client.worker_session(worker_id, request).await }, ) .map_err(|diagnostic| diagnostic.message) } @@ -4590,9 +4820,21 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { submission_request_id: None, segments: request.segments, }; - match self.post_json::<_, RuntimeHttpWorkerInputResponse>( - &format!("/v1/workers/{worker_id}/input"), - &input, + let input = match runtime_contract_convert(input) { + Ok(input) => input, + Err(diagnostic) => { + return WorkerInputResult { + state: WorkerOperationState::Rejected, + worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()), + diagnostics: vec![diagnostic], + }; + } + }; + let worker_id_owned = worker_id.to_string(); + match self.run_runtime_api( + self.request_timeout, + MAX_REMOTE_RUNTIME_RESPONSE_BYTES, + move |client| async move { client.send_worker_input(worker_id_owned, input).await }, ) { Ok(_) => WorkerInputResult { state: WorkerOperationState::Accepted, @@ -4660,13 +4902,21 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { worker_id: &str, request: WorkerCompletionsRequest, ) -> WorkerCompletionsResult { - let request = RuntimeHttpWorkerCompletionsRequest { + let request = runtime_api::CompletionRequest { kind: request.kind, prefix: request.prefix, }; - match self.post_json::<_, RuntimeHttpWorkerCompletionsResponse>( - &format!("/v1/workers/{worker_id}/completions"), - &request, + let failure_kind = request.kind; + let failure_prefix = request.prefix.clone(); + let worker_id_owned = worker_id.to_string(); + match self.run_runtime_api( + self.request_timeout, + MAX_REMOTE_RUNTIME_RESPONSE_BYTES, + move |client| async move { + client + .complete_worker_arguments(worker_id_owned, request) + .await + }, ) { Ok(response) => WorkerCompletionsResult { worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()), @@ -4677,8 +4927,8 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { }, Err(diagnostic) => WorkerCompletionsResult { worker: RuntimeWorkerRef::new(self.runtime_id.clone(), worker_id.to_string()), - kind: request.kind, - prefix: request.prefix, + kind: failure_kind, + prefix: failure_prefix, entries: Vec::new(), diagnostics: vec![diagnostic], }, @@ -5191,6 +5441,84 @@ fn remote_reqwest_diagnostic(runtime_id: &str, err: reqwest::Error) -> RuntimeDi } } +fn runtime_contract_convert(value: T) -> Result +where + T: Serialize, + U: DeserializeOwned, +{ + serde_json::to_value(value) + .and_then(serde_json::from_value) + .map_err(|error| { + diagnostic( + "remote_runtime_contract_conversion_failed", + DiagnosticSeverity::Error, + error.to_string(), + ) + }) +} + +fn runtime_api_diagnostic( + runtime_id: &str, + error: runtime_api::client_support::ClientError, +) -> RuntimeDiagnostic { + match error { + runtime_api::client_support::ClientError::Public { status, error } => { + let remote_code = error.error.code; + let message = sanitize_remote_runtime_message(&remote_code, &error.error.message); + let (code, severity) = match status { + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => ( + "remote_runtime_auth_failed".to_string(), + DiagnosticSeverity::Error, + ), + _ => ( + remote_code, + if status.is_server_error() { + DiagnosticSeverity::Error + } else { + DiagnosticSeverity::Warning + }, + ), + }; + diagnostic( + code, + severity, + format!( + "Remote Runtime '{runtime_id}' rejected request (HTTP {status}): {message}" + ), + ) + } + runtime_api::client_support::ClientError::Failure(failure) => { + let code = match failure { + runtime_api::client_support::ClientFailure::Timeout => "remote_runtime_timeout", + runtime_api::client_support::ClientFailure::Connect => { + "remote_runtime_network_unreachable" + } + runtime_api::client_support::ClientFailure::Authorization => { + "remote_runtime_authorization_failed" + } + runtime_api::client_support::ClientFailure::ErrorResponseDecode { status } + if status == StatusCode::UNAUTHORIZED.as_u16() + || status == StatusCode::FORBIDDEN.as_u16() => + { + "remote_runtime_auth_failed" + } + runtime_api::client_support::ClientFailure::ResponseTooLarge { .. } => { + "remote_runtime_response_too_large" + } + runtime_api::client_support::ClientFailure::Decode { .. } => { + "remote_runtime_invalid_response" + } + _ => "remote_runtime_client_error", + }; + diagnostic( + code, + DiagnosticSeverity::Error, + format!("Remote Runtime client error for '{runtime_id}': {failure}"), + ) + } + } +} + fn sanitize_remote_runtime_message(code: &str, message: &str) -> String { if message.contains('/') || message.contains('\\') { format!("remote Runtime returned {code}; backend-private details were omitted") @@ -5428,6 +5756,38 @@ mod tests { use std::sync::{Arc, Mutex}; use std::thread; + #[test] + fn remote_restore_transport_failure_requires_reconciliation() { + let provider = RemoteWorkerRuntime::new( + RemoteRuntimeConfig { + runtime_id: "runtime-remote".to_string(), + workspace_id: Some("workspace-a".to_string()), + display_name: "Remote Runtime".to_string(), + base_url: "http://127.0.0.1:9".to_string(), + bearer_token: None, + workspace_authorization: None, + strict_public_egress: false, + cached_worker_creation_available: true, + cached_os: "linux".to_string(), + cached_arch: "x86_64".to_string(), + cached_status: "ready".to_string(), + timeout: Duration::from_millis(100), + }, + "workspace-a".to_string(), + "http://127.0.0.1:3000".to_string(), + ) + .unwrap(); + + let result = provider.restore_worker("worker-transport-uncertain"); + + assert_eq!( + result.state, + server_api::WorkerRestoreState::ReconciliationRequired + ); + assert!(result.worker.is_none()); + assert_eq!(result.diagnostics.len(), 1); + } + #[test] fn embedded_delete_persistence_failure_diagnostic_is_bounded_and_path_free() { let diagnostic = @@ -5859,8 +6219,8 @@ mod tests { repository: worker_runtime::catalog::WorkingDirectoryRepository { id: "repository-1".to_string(), provider: "git".to_string(), - source: workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::LocalPath, + source: server_api::RepositorySource { + kind: server_api::RepositorySourceKind::LocalPath, uri: "/provider/repository.git".to_string(), }, source_revision: 7, diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index 50bb77b82..7cd9de68c 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -772,6 +772,47 @@ CREATE TABLE "worker_registry" ( PRIMARY KEY (workspace_id, worker_id), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ); +CREATE TABLE worker_registry_observations ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + availability TEXT NOT NULL CHECK (availability IN ('observed', 'unavailable')), + worker_json TEXT, + connection_generation INTEGER NOT NULL, + subject_revision INTEGER NOT NULL, + snapshot_revision INTEGER NOT NULL, + projection_revision INTEGER NOT NULL, + observed_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id, worker_id) + REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE +); +CREATE TABLE worker_registry_projection_cursors ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + connection_generation INTEGER NOT NULL, + snapshot_revision INTEGER NOT NULL, + PRIMARY KEY (workspace_id, runtime_id) +); +CREATE TABLE worker_registry_projection_revisions ( + workspace_id TEXT PRIMARY KEY, + revision INTEGER NOT NULL +); +CREATE TABLE worker_registry_projection_removals ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + projection_revision INTEGER NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id) +); +CREATE TABLE worker_registry_projection_diagnostics ( + diagnostic_id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + reason TEXT NOT NULL, + observed_at TEXT NOT NULL +); CREATE TABLE worker_removal_operations ( operation_id TEXT PRIMARY KEY, plan_id TEXT NOT NULL UNIQUE, input_fingerprint TEXT NOT NULL, workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 03c3e4963..a1ebc50e5 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -30,6 +30,7 @@ pub mod skills; pub mod store; pub mod workdir_create_operations; mod workdir_removal; +mod worker_projection; pub mod worker_source; pub mod workspace_catalog; mod workspace_deletion; @@ -48,11 +49,11 @@ pub use server::{ AuthConfig, ServerConfig, WorkspaceApi, WorkspaceServerApi, build_router, build_workspace_server_router, serve, serve_workspace_catalog, }; -pub use store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord}; -pub use workspace_api::{ +pub use server_api::{ GitCommitSummary, GitRemoteSummary, GitRepositorySummary, RepositorySummary, WorkspaceCreateResponse, }; +pub use store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord}; pub use workspace_catalog::{ InitialRepositoryIntent, WorkspaceCatalogService, WorkspaceCreateRequest, WorkspaceCreateResult, }; diff --git a/crates/workspace-server/src/memory_staging.rs b/crates/workspace-server/src/memory_staging.rs index 5ab7e01a7..ccc62c1db 100644 --- a/crates/workspace-server/src/memory_staging.rs +++ b/crates/workspace-server/src/memory_staging.rs @@ -1,6 +1,6 @@ use memory::extract::{CandidateKind, StagingRecord}; use memory::schema::{EvidenceOrigin, EvidenceOriginKind, SourceEvidenceRef, SourceRef}; -use workspace_api::{ +use server_api::{ Diagnostic, DiagnosticSeverity, MemoryCandidateKind, MemoryEvidenceOrigin, MemoryEvidenceOriginKind, MemorySourceEvidenceRef, MemorySourceRef, MemoryStagingEntry, MemoryStagingEvidence, MemoryStagingListResponse, MemoryStagingRecord, diff --git a/crates/workspace-server/src/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs index 0e37f4a9f..0cc848b80 100644 --- a/crates/workspace-server/src/profile_settings.rs +++ b/crates/workspace-server/src/profile_settings.rs @@ -4,16 +4,16 @@ use std::path::{Component, Path, PathBuf}; use config_source::{ConfigContentType, ConfigSchemaContribution, VirtualPath}; use manifest::{ProfileSource, builtin_profile_catalog_snapshot, resolve_profile_artifact_value}; use serde::Deserialize; +use server_api::{ + ProfileSettingsResponse, WorkspaceMetadataSettingsResponse, WorkspaceProfileSourceProvenance, + WorkspaceProfileSourceSummary, WorkspaceProfileSummary, +}; use sha2::{Digest, Sha256}; use worker::EffectivePromptCatalog; use worker_runtime::config_bundle::{ ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor, }; use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput}; -use workspace_api::{ - ProfileSettingsResponse, WorkspaceMetadataSettingsResponse, WorkspaceProfileSourceProvenance, - WorkspaceProfileSourceSummary, WorkspaceProfileSummary, -}; use crate::config_source::{ WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state, diff --git a/crates/workspace-server/src/records.rs b/crates/workspace-server/src/records.rs index 2cde25d89..8071de4f7 100644 --- a/crates/workspace-server/src/records.rs +++ b/crates/workspace-server/src/records.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -pub use workspace_api::{ +pub use server_api::{ ObjectiveDetail, ObjectiveEventDetail, ObjectiveLinkedTicketSummary, ObjectiveResourceSummary, ObjectiveSummary, QueryPage, }; diff --git a/crates/workspace-server/src/repositories.rs b/crates/workspace-server/src/repositories.rs index 4a4096042..69840f65e 100644 --- a/crates/workspace-server/src/repositories.rs +++ b/crates/workspace-server/src/repositories.rs @@ -4,7 +4,7 @@ use std::{ process::Command, }; -use workspace_api::{ +use server_api::{ Diagnostic, DiagnosticSeverity, GitCommitSummary, GitRemoteSummary, GitRepositorySummary, RepositoryDiagnostic, RepositoryObservedStatus, RepositorySource, RepositorySummary, }; @@ -629,7 +629,7 @@ mod tests { let projection = reader.list(); assert_eq!( projection.items[0].source.kind, - workspace_api::RepositorySourceKind::Invalid + server_api::RepositorySourceKind::Invalid ); let diagnostics = projection.items[0].diagnostics.as_ref().unwrap(); assert!(diagnostics.iter().any(|diagnostic| { @@ -642,7 +642,7 @@ mod tests { #[test] fn remote_source_is_visible_but_local_provider_operations_fail_closed() { let source = RepositorySource { - kind: workspace_api::RepositorySourceKind::Ssh, + kind: server_api::RepositorySourceKind::Ssh, uri: "git@example.test:org/repository.git".to_string(), }; let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository { @@ -660,10 +660,7 @@ mod tests { let projection = reader.list(); let summary = &projection.items[0]; - assert_eq!( - summary.source.kind, - workspace_api::RepositorySourceKind::Ssh - ); + assert_eq!(summary.source.kind, server_api::RepositorySourceKind::Ssh); assert_eq!( summary.observed_status, RepositoryObservedStatus::Unverified @@ -766,7 +763,7 @@ mod tests { ); let source_descriptor = RepositorySource { - kind: workspace_api::RepositorySourceKind::LocalPath, + kind: server_api::RepositorySourceKind::LocalPath, uri: path.display().to_string(), }; let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository { diff --git a/crates/workspace-server/src/repository_access.rs b/crates/workspace-server/src/repository_access.rs index 1efc9ec50..226db2532 100644 --- a/crates/workspace-server/src/repository_access.rs +++ b/crates/workspace-server/src/repository_access.rs @@ -11,16 +11,16 @@ use ring::hmac; use ring::rand::{SecureRandom, SystemRandom}; use rusqlite::{OptionalExtension, TransactionBehavior, params}; use serde::Deserialize; -use sha2::{Digest, Sha256}; -use ssh_key::private::Ed25519Keypair; -use ssh_key::{Algorithm, HashAlg, LineEnding, PrivateKey, PublicKey}; -use workspace_api::{ +use server_api::{ CreateRepositorySshCredentialRequest, DeleteRepositorySshCredentialRequest, DeleteRepositorySshHostTrustRequest, GenerateRepositorySshCredentialRequest, PutRepositorySshHostTrustRequest, RepositoryAccessMode, RepositoryAccessProjection, RepositorySshAccessBinding, RepositorySshCredential, RepositorySshHostTrust, RepositorySshPublicKey, RotateRepositorySshCredentialRequest, }; +use sha2::{Digest, Sha256}; +use ssh_key::private::Ed25519Keypair; +use ssh_key::{Algorithm, HashAlg, LineEnding, PrivateKey, PublicKey}; use crate::config_source::{ EvaluatedConfigCandidate, WorkspaceConfigSchemaProvider, WorkspaceConfigState, @@ -138,7 +138,7 @@ pub fn project_repository_access_state( fn validate_repository_access_source( repository_key: &str, - source: &workspace_api::RepositorySource, + source: &server_api::RepositorySource, ) -> Result<()> { if crate::repository_source::is_plain_http_repository_source(source) { return Err(Error::InvalidInput(format!( @@ -205,7 +205,7 @@ fn project_repository_access_evaluation( } let mut bindings = Vec::with_capacity(config.repository_access.len()); for (repository_key, access) in config.repository_access { - workspace_api::validate_repository_key(&repository_key) + server_api::validate_repository_key(&repository_key) .map_err(|error| Error::InvalidInput(format!("invalid Repository key: {error}")))?; validate_identifier("credential_id", &access.ssh.credential)?; validate_identifier("host_trust_id", &access.ssh.host_trust)?; @@ -213,7 +213,7 @@ fn project_repository_access_evaluation( .get_repository_by_key(workspace_id, &repository_key)? .ok_or_else(|| Error::InvalidInput(format!("unknown Repository `{repository_key}`")))?; validate_repository_access_source(&repository_key, &repository.source)?; - if repository.source.kind != workspace_api::RepositorySourceKind::Ssh { + if repository.source.kind != server_api::RepositorySourceKind::Ssh { return Err(Error::InvalidInput(format!( "Repository `{repository_key}` is not an ssh:// Repository" ))); @@ -1940,8 +1940,8 @@ mod tests { DEFAULT_SCHEMA_VERSION, SnapshotEnvironment, ToolchainContract, VirtualPath, WorkspaceConfigSchemaBundle, }; + use server_api::{RepositoryObservedStatus, RepositorySource, RepositorySourceKind}; use ssh_key::private::Ed25519Keypair; - use workspace_api::{RepositoryObservedStatus, RepositorySource, RepositorySourceKind}; fn test_private_key(seed: u8) -> (String, String) { let key = PrivateKey::from(Ed25519Keypair::from_seed(&[seed; 32])); @@ -1995,8 +1995,8 @@ mod tests { assert!(error.to_string().contains("unsupported plain HTTP")); assert!(error.to_string().contains("HTTPS or SSH")); - let mismatched = workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::Https, + let mismatched = server_api::RepositorySource { + kind: server_api::RepositorySourceKind::Https, uri: "http://git.example.test/team/project.git".to_string(), }; let error = validate_repository_access_source("remote", &mismatched).unwrap_err(); diff --git a/crates/workspace-server/src/repository_source.rs b/crates/workspace-server/src/repository_source.rs index 7d3bf4074..50690c4ae 100644 --- a/crates/workspace-server/src/repository_source.rs +++ b/crates/workspace-server/src/repository_source.rs @@ -1,8 +1,8 @@ use std::path::Path; +use server_api::{RepositorySource, RepositorySourceKind}; use sha2::{Digest, Sha256}; use url::Url; -use workspace_api::{RepositorySource, RepositorySourceKind}; use crate::{Error, Result}; diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 1bc1b6635..2447d25a8 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -26,48 +26,7 @@ use memory::backend::{ use protocol::Segment; use protocol::stream::{decode_method, encode_event}; use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use ticket::{ - MarkdownText, NewTicketEvent, TicketBackend, TicketBodyReplacement, TicketEventKind, - TicketIdOrSlug, TicketItemEdit, TicketStateChange, TicketTargetEdit, TicketWorkflowState, -}; -use ticket::{ - SqliteTicketBackend, TicketBackendOperation, TicketBackendOperationResult, - execute_ticket_backend_operation, -}; -use tokio::net::TcpListener; -use tokio::sync::Mutex as AsyncMutex; -use tokio_tungstenite::connect_async; -use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; -use tokio_tungstenite::tungstenite::client::IntoClientRequest; -use tower::ServiceExt; -use url::Url; -use uuid::Uuid; -use webauthn_rs::prelude::{ - Passkey, PasskeyAuthentication, PasskeyRegistration, Webauthn, WebauthnBuilder, -}; -use workdir::http::{ - WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError, -}; -use workdir::workspace::{ - MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryOccupancy, - WorkingDirectoryStatusKind, WorkingDirectorySummary, WorkspaceWorkdirSessionOperationRequest, -}; -use workdir::{CommandHandle, WorkdirSessionHandle}; -use worker::feature::builtin::{WorkerObservationSubject, WorkerObservationSubjectRef}; -use worker_runtime::http_server::{ - RUNTIME_HTTP_PROTOCOL_MAX_VERSION, RUNTIME_HTTP_PROTOCOL_MIN_VERSION, - RUNTIME_HTTP_PROTOCOL_VERSION, -}; -use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest}; -use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; -use worker_runtime::workspace_issuer::{ - WORKSPACE_VERIFICATION_ACK_PATH, WORKSPACE_VERIFICATION_CHALLENGE_PATH, - WORKSPACE_VERIFICATION_OPERATION, WorkspaceCapabilityClaims, - WorkspaceRuntimeVerificationAcknowledgement, WorkspaceRuntimeVerificationChallenge, - verify_runtime_verification_response, workspace_request_body_digest, -}; -use workspace_api::{ +use server_api::{ ActorAuthMethod, AuthBootstrapUserRequest, AuthPublicConfig, AuthUserResponse, AuthenticatedUser, BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, ConfirmRepositorySshHostTrustRequest, CreateRemoteRuntimeRequest, @@ -114,6 +73,47 @@ use workspace_api::{ WorkspaceSigningIdentityState, WorkspaceSummary, WorkspaceWorkerDiscoveryItem, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject, }; +use sha2::{Digest, Sha256}; +use ticket::{ + MarkdownText, NewTicketEvent, TicketBackend, TicketBodyReplacement, TicketEventKind, + TicketIdOrSlug, TicketItemEdit, TicketStateChange, TicketTargetEdit, TicketWorkflowState, +}; +use ticket::{ + SqliteTicketBackend, TicketBackendOperation, TicketBackendOperationResult, + execute_ticket_backend_operation, +}; +use tokio::net::TcpListener; +use tokio::sync::Mutex as AsyncMutex; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tower::ServiceExt; +use url::Url; +use uuid::Uuid; +use webauthn_rs::prelude::{ + Passkey, PasskeyAuthentication, PasskeyRegistration, Webauthn, WebauthnBuilder, +}; +use workdir::http::{ + WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError, +}; +use workdir::workspace::{ + MaterializerKind, WorkingDirectoryCleanupTarget, WorkingDirectoryOccupancy, + WorkingDirectoryStatusKind, WorkingDirectorySummary, WorkspaceWorkdirSessionOperationRequest, +}; +use workdir::{CommandHandle, WorkdirSessionHandle}; +use worker::feature::builtin::{WorkerObservationSubject, WorkerObservationSubjectRef}; +use worker_runtime::http_server::{ + RUNTIME_HTTP_PROTOCOL_MAX_VERSION, RUNTIME_HTTP_PROTOCOL_MIN_VERSION, + RUNTIME_HTTP_PROTOCOL_VERSION, +}; +use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest}; +use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; +use worker_runtime::workspace_issuer::{ + WORKSPACE_VERIFICATION_ACK_PATH, WORKSPACE_VERIFICATION_CHALLENGE_PATH, + WORKSPACE_VERIFICATION_OPERATION, WorkspaceCapabilityClaims, + WorkspaceRuntimeVerificationAcknowledgement, WorkspaceRuntimeVerificationChallenge, + verify_runtime_verification_response, workspace_request_body_digest, +}; use crate::auth::{ SessionCookiePolicy, auth_error, is_expired, mint_secret, new_id, new_user_code, @@ -203,7 +203,7 @@ use worker_runtime::identity::{RuntimeWorkerRef, WorkerId}; const EMBEDDED_WORKER_RUNTIME_ID: &str = "embedded-worker-runtime"; -pub use workspace_api::WorkspaceAuthConfig as AuthConfig; +pub use server_api::WorkspaceAuthConfig as AuthConfig; #[derive(Clone)] pub struct ServerConfig { @@ -360,15 +360,15 @@ impl ServerConfig { } } -fn repository_local_path(source: &workspace_api::RepositorySource) -> Option { +fn repository_local_path(source: &server_api::RepositorySource) -> Option { match source.kind { - workspace_api::RepositorySourceKind::LocalPath => Some(PathBuf::from(&source.uri)), - workspace_api::RepositorySourceKind::File => url::Url::parse(&source.uri) + server_api::RepositorySourceKind::LocalPath => Some(PathBuf::from(&source.uri)), + server_api::RepositorySourceKind::File => url::Url::parse(&source.uri) .ok() .and_then(|uri| uri.to_file_path().ok()), - workspace_api::RepositorySourceKind::Ssh - | workspace_api::RepositorySourceKind::Https - | workspace_api::RepositorySourceKind::Invalid => None, + server_api::RepositorySourceKind::Ssh + | server_api::RepositorySourceKind::Https + | server_api::RepositorySourceKind::Invalid => None, } } @@ -602,6 +602,18 @@ impl AttachmentUploadGrant { } } +struct WorkerProjectionShutdownGuard { + service: std::sync::Weak, +} + +impl Drop for WorkerProjectionShutdownGuard { + fn drop(&mut self) { + if let Some(service) = self.service.upgrade() { + service.shutdown(); + } + } +} + #[derive(Clone)] pub struct WorkspaceApi { pub(crate) config: ServerConfig, @@ -612,6 +624,7 @@ pub struct WorkspaceApi { config_schema_registry: crate::config_source::WorkspaceConfigSchemaRegistry, prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache, authority: SqliteWorkspaceAuthority, + _worker_projection_shutdown: Arc, runtime: Arc, runtime_binding_expectations: Arc>>, companion: Arc, @@ -619,6 +632,7 @@ pub struct WorkspaceApi { orchestrator_attention_fingerprint: Arc>>, observation_proxy: BackendObservationProxy, runtime_subscription_broker: RuntimeSubscriptionBroker, + pub(crate) worker_projection: Arc, resource_broker: BackendResourceBroker, workdir_sessions: Arc>, workdir_session_locks: Arc>>>>, @@ -653,6 +667,7 @@ struct WorkspaceWorkerRemoveExecutor { workdir_session_locks: Arc>>>>, worker_remove_locks: Arc>>>>, worker_control_locks: Arc>>>>, + worker_projection: std::sync::Weak, } impl WorkspaceWorkerRemoveExecutor { @@ -665,6 +680,7 @@ impl WorkspaceWorkerRemoveExecutor { workdir_session_locks: api.workdir_session_locks.clone(), worker_remove_locks: api.worker_remove_locks.clone(), worker_control_locks: api.worker_control_locks.clone(), + worker_projection: Arc::downgrade(&api.worker_projection), } } @@ -691,7 +707,12 @@ impl WorkspaceWorkerRemoveExecutor { &prepared.plan.input_fingerprint, &result, ) { - Ok(_) => Ok(worker_remove_success_response(target)), + Ok(_) => { + if let Some(worker_projection) = self.worker_projection.upgrade() { + let _ = worker_projection.publish_removed(target.clone()); + } + Ok(worker_remove_success_response(target)) + } Err(error) => Ok(worker_retention_error_response(error)), } } @@ -1002,7 +1023,11 @@ impl WorkspaceWorkerRemoveExecutor { &plan.input_fingerprint, &retention_result, ) { - Ok(_) => {} + Ok(_) => { + if let Some(worker_projection) = self.worker_projection.upgrade() { + let _ = worker_projection.publish_removed(plan.worker.clone()); + } + } Err(error) => { let _ = self.store.fail_worker_removal( &self.workspace_id, @@ -1604,11 +1629,31 @@ fn signed_request_target(uri: &Uri) -> &str { .unwrap_or_else(|| uri.path()) } +fn repository_api_rejection(path: &str, status: StatusCode, message: &str) -> Response { + if path == "/api/repositories" + || path.starts_with("/api/repositories/") + || (path.starts_with("/api/w/") && path.contains("/repositories")) + { + return ( + status, + Json(server_api::RepositoryApiError::new( + status.as_u16(), + status.canonical_reason().unwrap_or("error"), + message, + Vec::new(), + )), + ) + .into_response(); + } + status.into_response() +} + async fn authorize_scoped_workspace_request( api: &WorkspaceServerApi, workspace_id: &str, request: &mut Request, ) -> std::result::Result<(), Response> { + let request_path = request.uri().path().to_owned(); let proof = request .headers() .get(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER) @@ -1620,7 +1665,13 @@ async fn authorize_scoped_workspace_request( let body = std::mem::take(request.body_mut()); let body = axum::body::to_bytes(body, 16 * 1024 * 1024) .await - .map_err(|_| StatusCode::BAD_REQUEST.into_response())?; + .map_err(|_| { + repository_api_rejection( + &request_path, + StatusCode::BAD_REQUEST, + "invalid request body", + ) + })?; let digest = worker_runtime::auth::request_body_digest(&body); *request.body_mut() = axum::body::Body::from(body); let permission = if path @@ -1650,7 +1701,13 @@ async fn authorize_scoped_workspace_request( &digest, ) .await - .map_err(|_| StatusCode::UNAUTHORIZED.into_response())?; + .map_err(|_| { + repository_api_rejection( + &request_path, + StatusCode::UNAUTHORIZED, + "invalid runtime request proof", + ) + })?; request.extensions_mut().insert(source); if !matches!( *request.method(), @@ -1662,11 +1719,11 @@ async fn authorize_scoped_workspace_request( .map_err(server_error_response)? .is_some_and(|workspace| workspace.state == "active") { - return Err(( + return Err(repository_api_rejection( + &request_path, StatusCode::CONFLICT, "Workspace is deleting and no longer accepts mutations", - ) - .into_response()); + )); } return Ok(()); } @@ -1674,7 +1731,13 @@ async fn authorize_scoped_workspace_request( let actor = resolve_server_actor(api, request.headers()) .await .map_err(server_error_response)? - .ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())?; + .ok_or_else(|| { + repository_api_rejection( + &request_path, + StatusCode::UNAUTHORIZED, + "authentication required", + ) + })?; let cookie_authenticated = matches!(actor.auth_method, ActorAuthMethod::BrowserSession); let mutating = !matches!( @@ -1690,7 +1753,11 @@ async fn authorize_scoped_workspace_request( .and_then(|value| value.to_str().ok()) .unwrap_or_default() { - return Err(StatusCode::FORBIDDEN.into_response()); + return Err(repository_api_rejection( + &request_path, + StatusCode::FORBIDDEN, + "cross-origin browser mutation denied", + )); } } request.extensions_mut().insert(actor); @@ -1702,11 +1769,11 @@ async fn authorize_scoped_workspace_request( .map_err(server_error_response)? .is_some_and(|workspace| workspace.state == "active") { - return Err(( + return Err(repository_api_rejection( + &request_path, StatusCode::CONFLICT, "Workspace is deleting and no longer accepts mutations", - ) - .into_response()); + )); } Ok(()) } @@ -1719,6 +1786,7 @@ async fn authorize_workspace_api_request( if !request.uri().path().starts_with("/api/") { return next.run(request).await; } + let request_path = request.uri().path().to_owned(); let public_server_api = is_server_global_forward(request.uri().path()); let workspace_id = api.workspace_id().to_owned(); let proof = request @@ -1731,7 +1799,11 @@ async fn authorize_workspace_api_request( let path = signed_request_target(request.uri()).to_owned(); let body = std::mem::take(request.body_mut()); let Ok(body) = axum::body::to_bytes(body, 16 * 1024 * 1024).await else { - return StatusCode::BAD_REQUEST.into_response(); + return repository_api_rejection( + &request_path, + StatusCode::BAD_REQUEST, + "invalid request body", + ); }; let digest = worker_runtime::auth::request_body_digest(&body); *request.body_mut() = axum::body::Body::from(body); @@ -1762,7 +1834,11 @@ async fn authorize_workspace_api_request( ) .await else { - return StatusCode::UNAUTHORIZED.into_response(); + return repository_api_rejection( + &request_path, + StatusCode::UNAUTHORIZED, + "invalid runtime request proof", + ); }; request.extensions_mut().insert(source); if !matches!( @@ -1776,11 +1852,11 @@ async fn authorize_workspace_api_request( .flatten() .is_some_and(|workspace| workspace.state == "active") { - return ( + return repository_api_rejection( + &request_path, StatusCode::CONFLICT, "Workspace is deleting and no longer accepts mutations", - ) - .into_response(); + ); } return next.run(request).await; } @@ -1795,7 +1871,13 @@ async fn authorize_workspace_api_request( { Ok(Some(actor)) => actor, Ok(None) if public_server_api => return next.run(request).await, - Ok(None) | Err(_) => return StatusCode::UNAUTHORIZED.into_response(), + Ok(None) | Err(_) => { + return repository_api_rejection( + &request_path, + StatusCode::UNAUTHORIZED, + "authentication required", + ); + } }; let cookie_authenticated = matches!(actor.auth_method, ActorAuthMethod::BrowserSession); let mutating = !matches!( @@ -1811,7 +1893,11 @@ async fn authorize_workspace_api_request( .and_then(|value| value.to_str().ok()) .unwrap_or_default() { - return StatusCode::FORBIDDEN.into_response(); + return repository_api_rejection( + &request_path, + StatusCode::FORBIDDEN, + "cross-origin browser mutation denied", + ); } } request.extensions_mut().insert(actor); @@ -1824,11 +1910,11 @@ async fn authorize_workspace_api_request( .flatten() .is_some_and(|workspace| workspace.state == "active") { - return ( + return repository_api_rejection( + &request_path, StatusCode::CONFLICT, "Workspace is deleting and no longer accepts mutations", - ) - .into_response(); + ); } next.run(request).await } @@ -1939,7 +2025,7 @@ async fn dispatch_workspace_request( } }; let Some(router) = router else { - return StatusCode::NOT_FOUND.into_response(); + return repository_api_rejection(&path, StatusCode::NOT_FOUND, "workspace was not found"); }; match router.oneshot(request).await { Ok(response) => response, @@ -2338,6 +2424,16 @@ impl WorkspaceApi { &Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), config_schema_registry.compose()?, )?; + let worker_projection = crate::worker_projection::WorkerProjectionService::new( + config.workspace_id.clone(), + store.clone(), + ); + for runtime_id in runtime_subscription_broker.runtime_ids() { + worker_projection + .attach_runtime(&runtime_subscription_broker, runtime_id.as_str()) + .await + .map_err(|error| Error::InvalidInput(error.to_string()))?; + } let api = Self { config_store, repository_secrets, @@ -2353,6 +2449,9 @@ impl WorkspaceApi { workspace_id: config.workspace_id.clone(), reader: RepositoryRegistryReader::new(config.repositories.clone()), })), + _worker_projection_shutdown: Arc::new(WorkerProjectionShutdownGuard { + service: Arc::downgrade(&worker_projection), + }), config, store, runtime, @@ -2362,6 +2461,7 @@ impl WorkspaceApi { orchestrator_attention_fingerprint: Arc::new(Mutex::new(None)), observation_proxy, runtime_subscription_broker, + worker_projection, resource_broker, workdir_sessions: Arc::new(Mutex::new(WorkdirSessionRegistry::default())), workdir_session_locks: Arc::new(Mutex::new(HashMap::new())), @@ -2410,9 +2510,20 @@ impl WorkspaceApi { ) .map(|runtime| runtime.with_resource_broker(self.resource_broker.clone())) .map_err(|error| error.into_error())?; + let runtime_id = remote_config.runtime_id.clone(); self.runtime.register_or_replace(remote_runtime); self.runtime_subscription_broker .register_remote_runtime(remote_config); + let projection = self.worker_projection.clone(); + let broker = self.runtime_subscription_broker.clone(); + tokio::spawn(async move { + if let Err(error) = projection + .attach_runtime(&broker, runtime_id.as_str()) + .await + { + tracing::warn!(%runtime_id, %error, "failed to attach Worker projection Runtime"); + } + }); Ok(()) } @@ -2764,6 +2875,15 @@ impl WorkspaceApi { return Err(api_error_with_additional_diagnostics(error, diagnostics)); } } + if attachment_reservation.is_none() { + record_worker_summary( + self, + worker, + worker.label.as_str(), + worker.profile.clone(), + WorkerRegistryDisplayNamePolicy::PreserveExisting, + )?; + } if let Err(error) = self .config_store .complete_worker_create_reservation(&self.config.workspace_id, worker_id) @@ -2814,7 +2934,7 @@ impl WorkspaceApi { .map_err(|error| error.into_error())?; if binding.state != WorkerOperationState::Accepted { return Ok(WorkerRestoreResult { - state: binding.state, + state: server_api::WorkerRestoreState::Rejected, worker: binding.worker, diagnostics: binding.diagnostics, }); @@ -2858,7 +2978,7 @@ impl WorkspaceApi { &self, repository_key: &str, ) -> ApiResult { - workspace_api::validate_repository_key(repository_key).map_err(|error| { + server_api::validate_repository_key(repository_key).map_err(|error| { ApiError::from(Error::InvalidInput(format!( "invalid Repository key: {error}" ))) @@ -3040,6 +3160,113 @@ fn build_server_auth_router(api: ServerAuthApi) -> Router { .with_state(api) } +impl server_api::ServerApi for WorkspaceApi { + async fn worker_session( + &self, + workspace_id: String, + runtime_id: String, + worker_id: String, + ) -> std::result::Result + { + if workspace_id != self.config.workspace_id { + return Err(runtime_api::RuntimeApiError::new( + StatusCode::NOT_FOUND.as_u16(), + "workspace_not_found", + "Workspace was not found", + )); + } + let worker = RuntimeWorkerRef::new(runtime_id.clone(), worker_id.clone()); + let observation = self + .runtime + .worker_session(&workspace_id, &worker) + .map_err(|_error| { + runtime_api::RuntimeApiError::new( + StatusCode::BAD_GATEWAY.as_u16(), + "worker_session_observation_failed", + "Worker Session observation failed", + ) + })?; + Ok(server_api::WorkspaceWorkerSessionResponse { + subject: server_api::WorkspaceWorkerSubject::RuntimeWorker { + runtime_id, + worker_id, + }, + observation, + }) + } + + async fn repository_list( + &self, + workspace_id: String, + ) -> std::result::Result + { + scoped_list_repositories( + State(self.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn repository_list_alias( + &self, + ) -> std::result::Result + { + list_repositories(State(self.clone())) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn repository_detail( + &self, + workspace_id: String, + repository_key: String, + ) -> std::result::Result + { + scoped_repository_detail( + State(self.clone()), + AxumPath(ScopedRepositoryPath { + workspace_id, + repository_key, + }), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn repository_detail_alias( + &self, + repository_key: String, + ) -> std::result::Result + { + repository_detail(State(self.clone()), AxumPath(repository_key)) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + + async fn repository_create( + &self, + actor: RequestActor, + workspace_id: String, + request: CreateWorkspaceRepositoryRequest, + ) -> std::result::Result + { + scoped_create_repository( + State(self.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + Extension(actor), + Json(request), + ) + .await + .map(|(_status, Json(response))| response) + .map_err(ApiError::into_repository_api_error) + } +} + fn build_inner_router(api: WorkspaceApi) -> Router { let auth = build_server_auth_router(ServerAuthApi::from(&api)); let scoped_ticket_relations_query_path = @@ -3362,16 +3589,6 @@ fn build_inner_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/objectives/{objective_id}/ticket-links/{ticket_id}", delete(scoped_unlink_objective_ticket), ) - .route("/api/repositories", get(list_repositories)) - .route( - "/api/w/{workspace_id}/repositories", - get(scoped_list_repositories).post(scoped_create_repository), - ) - .route("/api/repositories/{repository_key}", get(repository_detail)) - .route( - "/api/w/{workspace_id}/repositories/{repository_key}", - get(scoped_repository_detail), - ) .route( "/api/w/{workspace_id}/repositories/{repository_key}/ssh-connection-test", post(scoped_probe_repository_ssh_connection) @@ -3632,6 +3849,9 @@ fn build_inner_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/hosts/{host_id}/workers", get(scoped_list_host_workers), ) + .merge( + server_api::ServerApiAxum::router(api.clone()).with_state::(()), + ) .fallback(get(static_or_spa_fallback)) .with_state(api); auth.merge(workspace) @@ -4252,9 +4472,9 @@ async fn scoped_update_workspace_settings( let workspace = crate::profile_settings::workspace_metadata_settings(&workspace); Ok(Json(WorkspaceMetadataMutationResponse { workspace, - diagnostics: vec![workspace_api::Diagnostic { + diagnostics: vec![server_api::Diagnostic { code: "workspace_metadata_updated".to_string(), - severity: workspace_api::DiagnosticSeverity::Info, + severity: server_api::DiagnosticSeverity::Info, message: "Workspace display metadata was updated.".to_string(), }], })) @@ -4414,13 +4634,13 @@ struct WorkspaceConfigTreeResponse { async fn scoped_get_workspace_memory_settings( State(api): State, AxumPath(workspace_id): AxumPath, -) -> ApiResult> { +) -> ApiResult> { validate_workspace_scope(&api, &workspace_id)?; let settings = api .config_store .get_workspace_memory_settings(&workspace_id) .map_err(ApiError::from)?; - Ok(Json(workspace_api::WorkspaceMemorySettings { + Ok(Json(server_api::WorkspaceMemorySettings { workspace_id: settings.workspace_id, settings_revision: settings.settings_revision, language: settings.language, @@ -4430,8 +4650,8 @@ async fn scoped_get_workspace_memory_settings( async fn scoped_update_workspace_memory_settings( State(api): State, AxumPath(workspace_id): AxumPath, - Json(request): Json, -) -> ApiResult> { + Json(request): Json, +) -> ApiResult> { validate_workspace_scope(&api, &workspace_id)?; let settings = api .config_store @@ -4441,7 +4661,7 @@ async fn scoped_update_workspace_memory_settings( &request.language, ) .map_err(ApiError::from)?; - Ok(Json(workspace_api::WorkspaceMemorySettings { + Ok(Json(server_api::WorkspaceMemorySettings { workspace_id: settings.workspace_id, settings_revision: settings.settings_revision, language: settings.language, @@ -5578,7 +5798,7 @@ async fn scoped_edit_ticket_item( validate_workspace_scope(&api, &path.workspace_id)?; let mut target = request.target; if let Some(TicketTargetEdit::Set { repository_id, .. }) = target.as_mut() { - workspace_api::validate_repository_key(repository_id).map_err(|_| { + server_api::validate_repository_key(repository_id).map_err(|_| { settings_bad_request( "repository_key_invalid", "Repository key must contain 1-64 lowercase ASCII letters, digits, or hyphens without a leading or trailing hyphen", @@ -5777,7 +5997,7 @@ fn resolve_ticket_operation_repository_keys( let Some(repository_key) = submitted_key else { return Ok(()); }; - workspace_api::validate_repository_key(repository_key).map_err(|error| { + server_api::validate_repository_key(repository_key).map_err(|error| { ApiError::from(Error::InvalidInput(format!( "invalid Repository key: {error}" ))) @@ -8174,7 +8394,7 @@ async fn open_current_worker_workdir_session_locked( .require_configured_workspace_repository(&workdir.repository_id) .map_err(|error| error.error)?; let repository_requires_access = - repository.source.kind == workspace_api::RepositorySourceKind::Ssh; + repository.source.kind == server_api::RepositorySourceKind::Ssh; if repository_requires_access { close_current_worker_attachment_session_locked(api, worker).await?; let access = repository_access_request_for_workdir( @@ -9304,7 +9524,7 @@ async fn scoped_create_repository( ) -> ApiResult<(StatusCode, Json)> { require_workspace_owner(&api, &path.workspace_id, &actor, "ManageRepositories").await?; - workspace_api::validate_repository_key(&request.repository_key) + server_api::validate_repository_key(&request.repository_key) .map_err(|error| Error::InvalidInput(format!("invalid Repository key: {error}")))?; let repository_key = request.repository_key; let default_ref = request @@ -9325,7 +9545,7 @@ async fn scoped_create_repository( source, default_ref, source_revision: 1, - observed_status: workspace_api::RepositoryObservedStatus::Unverified, + observed_status: server_api::RepositoryObservedStatus::Unverified, observed_at: None, created_at: now.clone(), updated_at: now, @@ -9645,7 +9865,7 @@ async fn get_latest_workspace_runtime_config( async fn scoped_list_runtimes( State(api): State, AxumPath(path): AxumPath, -) -> ApiResult>> { +) -> ApiResult>> { validate_workspace_scope(&api, &path.workspace_id)?; Ok(Json( workspace_runtime_resources_response(&api, &api.config.workspace_id).await?, @@ -9837,7 +10057,7 @@ async fn scoped_worker_remove_source_boundary( async fn scoped_get_workspace_worker( State(api): State, AxumPath(path): AxumPath, -) -> ApiResult> { +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; let worker_id = api .store @@ -9933,7 +10153,7 @@ async fn scoped_discover_workspace_workers( } fn workspace_worker_discovery_page( - workers: Vec, + workers: Vec, query: Option<&str>, offset: usize, limit: usize, @@ -10004,7 +10224,7 @@ fn workspace_worker_discovery_filter_fingerprint(query: Option<&str>) -> u64 { async fn scoped_list_workers( State(api): State, AxumPath(path): AxumPath, -) -> ApiResult>> { +) -> ApiResult>> { validate_workspace_scope(&api, &path.workspace_id)?; list_workers(State(api)).await } @@ -10270,7 +10490,7 @@ async fn restore_known_worker( State(api): State, AxumPath(path): AxumPath, headers: HeaderMap, -) -> ApiResult> { +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?; let subject = path.worker.clone(); @@ -10434,7 +10654,7 @@ async fn scoped_start_workspace_orchestrator( .runtime .restore_worker(&existing.worker) .map_err(|error| error.into_error())?; - if restored.state == WorkerOperationState::Accepted { + if restored.state == server_api::WorkerRestoreState::Accepted { *api.orchestrator_attention_fingerprint .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; @@ -10527,7 +10747,7 @@ fn worker_launch_worker_summary(worker: WorkerSummary) -> WorkerLaunchWorkerSumm profile: worker.profile, singleton_key: worker.singleton_key, tags: worker.tags, - workspace: workspace_api::WorkerWorkspaceSummary { + workspace: server_api::WorkerWorkspaceSummary { visibility: worker.workspace.visibility, identity: worker.workspace.identity, workspace_id: worker.workspace.workspace_id, @@ -10536,19 +10756,19 @@ fn worker_launch_worker_summary(worker: WorkerSummary) -> WorkerLaunchWorkerSumm last_seen_at: worker.last_seen_at, pinned: worker.pinned, retention_state: worker.retention_state, - implementation: workspace_api::WorkerImplementationSummary { + implementation: server_api::WorkerImplementationSummary { kind: worker.implementation.kind, display_hint: worker.implementation.display_hint, }, - capabilities: workspace_api::WorkerCapabilitySummary { + capabilities: server_api::WorkerCapabilitySummary { can_stop: worker.capabilities.can_stop, can_spawn_followup: worker.capabilities.can_spawn_followup, }, - working_directory: worker.working_directory, + working_directory: worker.working_directory.map(Into::into), diagnostics: worker .diagnostics .into_iter() - .map(workspace_api::Diagnostic::from) + .map(server_api::Diagnostic::from) .collect(), } } @@ -10568,7 +10788,7 @@ fn workspace_orchestrator_response( .diagnostics .iter() .cloned() - .map(workspace_api::Diagnostic::from) + .map(server_api::Diagnostic::from) .collect() }) .unwrap_or_default(); @@ -10605,15 +10825,15 @@ async fn scoped_get_worker_launch_options( fn working_directory_diagnostics( diagnostics: Vec, -) -> Vec { +) -> Vec { diagnostics .into_iter() - .map(|diagnostic| workspace_api::Diagnostic { + .map(|diagnostic| server_api::Diagnostic { code: diagnostic.code, severity: match diagnostic.severity { - DiagnosticSeverity::Info => workspace_api::DiagnosticSeverity::Info, - DiagnosticSeverity::Warning => workspace_api::DiagnosticSeverity::Warning, - DiagnosticSeverity::Error => workspace_api::DiagnosticSeverity::Error, + DiagnosticSeverity::Info => server_api::DiagnosticSeverity::Info, + DiagnosticSeverity::Warning => server_api::DiagnosticSeverity::Warning, + DiagnosticSeverity::Error => server_api::DiagnosticSeverity::Error, }, message: diagnostic.message, }) @@ -10642,6 +10862,7 @@ async fn scoped_list_runtime_working_directories( validate_workspace_scope(&api, &path.workspace_id)?; require_active_workspace_runtime_binding(&api, &path.runtime_id).await?; let (items, diagnostics) = runtime_working_directory_summaries(&api, &path.runtime_id)?; + let items = items.into_iter().map(Into::into).collect(); Ok(Json(BrowserWorkingDirectoryListResponse { workspace_id: api.config.workspace_id.clone(), items, @@ -10713,6 +10934,7 @@ async fn scoped_list_working_directories( ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; let items = working_directory_summaries(&api)?; + let items = items.into_iter().map(Into::into).collect(); Ok(Json(BrowserWorkingDirectoryListResponse { workspace_id: api.config.workspace_id.clone(), items, @@ -10879,11 +11101,11 @@ async fn create_workspace_working_directory( existing.source_fingerprint.clone(), ) { let kind = match kind { - "local_path" => workspace_api::RepositorySourceKind::LocalPath, - "file" => workspace_api::RepositorySourceKind::File, - "https" => workspace_api::RepositorySourceKind::Https, - "http" | "invalid" => workspace_api::RepositorySourceKind::Invalid, - "ssh" => workspace_api::RepositorySourceKind::Ssh, + "local_path" => server_api::RepositorySourceKind::LocalPath, + "file" => server_api::RepositorySourceKind::File, + "https" => server_api::RepositorySourceKind::Https, + "http" | "invalid" => server_api::RepositorySourceKind::Invalid, + "ssh" => server_api::RepositorySourceKind::Ssh, _ => { return Err(settings_bad_request( "working_directory_repository_source_invalid", @@ -10892,7 +11114,7 @@ async fn create_workspace_working_directory( } }; working_directory_request.repository.source = - workspace_api::RepositorySource { kind, uri }; + server_api::RepositorySource { kind, uri }; working_directory_request.repository.source_revision = revision; working_directory_request.repository.source_fingerprint = fingerprint; } @@ -11198,7 +11420,7 @@ async fn create_workspace_working_directory( Json(BrowserWorkingDirectoryCreateResponse { workspace_id: workspace_id.to_string(), runtime_id: reserved.resolved_runtime_id, - item: summary, + item: summary.into(), diagnostics: working_directory_diagnostics(result.diagnostics), }), )) @@ -11239,14 +11461,14 @@ fn working_directory_detail_for_runtime( return Ok(Json(BrowserWorkingDirectoryDetailResponse { workspace_id: api.config.workspace_id.clone(), runtime_id: runtime_id.to_string(), - item: summary, + item: summary.into(), diagnostics: working_directory_diagnostics(result.diagnostics), })); } Ok(Json(BrowserWorkingDirectoryDetailResponse { workspace_id: api.config.workspace_id.clone(), runtime_id: runtime_id.to_string(), - item: projected_workdir_summary_from_record(&api, &existing)?, + item: projected_workdir_summary_from_record(&api, &existing)?.into(), diagnostics: working_directory_diagnostics(result.diagnostics), })) } @@ -12359,7 +12581,7 @@ async fn scoped_test_runtime_connection( } if binding.authentication_mode == StoredRuntimeAuthenticationMode::WorkspaceIdentity { validate_runtime_connection_request(&CreateRemoteRuntimeRequest { - public_bundle: workspace_api::RuntimePublicIdentityBundle { + public_bundle: server_api::RuntimePublicIdentityBundle { identity_id: binding.runtime_id.clone(), public_key: binding.public_key.clone(), }, @@ -12411,7 +12633,7 @@ async fn scoped_list_runtime_workers( State(api): State, AxumPath(path): AxumPath, Query(query): Query, -) -> ApiResult>> { +) -> ApiResult>> { validate_workspace_scope(&api, &path.workspace_id)?; list_runtime_workers(State(api), AxumPath(path.runtime_id), Query(query)).await } @@ -12470,7 +12692,7 @@ async fn scoped_restore_runtime_worker( State(api): State, AxumPath(path): AxumPath, Query(query): Query, -) -> ApiResult> { +) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; let workspace_id = path.workspace_id.clone(); let runtime_id = path.worker.runtime_id.clone(); @@ -12525,12 +12747,12 @@ async fn scoped_restore_runtime_worker( assign_ticket_worker_from_lifecycle(&api, assignment, &runtime_id, &worker_id)?; accept_queued_ticket_after_worker_spawn(&api, assignment)?; let worker = project_workspace_worker(&api, worker)?; - return Ok(Json(workspace_api::WorkerRestoreResponse { + return Ok(Json(server_api::WorkerRestoreResponse { workspace_id, runtime_id: runtime_id.clone(), worker_id: worker_id.clone(), - result: workspace_api::WorkerRestoreResult { - state: workspace_api::WorkerOperationState::Accepted, + result: server_api::WorkerRestoreResult { + state: server_api::WorkerRestoreState::Accepted, worker: Some(worker), diagnostics: Vec::new(), }, @@ -12914,7 +13136,7 @@ async fn scoped_worker_protocol_ws( async fn scoped_list_host_workers( State(api): State, AxumPath(path): AxumPath, -) -> ApiResult>> { +) -> ApiResult>> { validate_workspace_scope(&api, &path.workspace_id)?; list_host_workers(State(api), AxumPath(path.host_id)).await } @@ -13635,9 +13857,9 @@ fn companion_console_extension_point( status: &CompanionStatusResponse, ) -> WorkspaceExtensionPointState { let extension_status = match status.state { - workspace_api::CompanionLifecycleState::Idle => "idle", - workspace_api::CompanionLifecycleState::Running => "running", - workspace_api::CompanionLifecycleState::Stopped => "stopped", + server_api::CompanionLifecycleState::Idle => "idle", + server_api::CompanionLifecycleState::Running => "running", + server_api::CompanionLifecycleState::Stopped => "stopped", } .to_string(); let diagnostic_codes = status @@ -13749,7 +13971,7 @@ async fn repository_detail( State(api): State, AxumPath(repository_key): AxumPath, ) -> ApiResult> { - workspace_api::validate_repository_key(&repository_key).map_err(|error| { + server_api::validate_repository_key(&repository_key).map_err(|error| { ApiError::from(Error::InvalidInput(format!( "invalid Repository key: {error}" ))) @@ -13770,7 +13992,7 @@ async fn repository_log( AxumPath(repository_key): AxumPath, Query(query): Query, ) -> ApiResult> { - workspace_api::validate_repository_key(&repository_key).map_err(|error| { + server_api::validate_repository_key(&repository_key).map_err(|error| { ApiError::from(Error::InvalidInput(format!( "invalid Repository key: {error}" ))) @@ -13811,10 +14033,10 @@ async fn list_hosts( async fn list_runtimes( State(api): State, -) -> ApiResult>> { +) -> ApiResult>> { let limit = api.config.max_records.min(200); let runtimes = api.runtime.list_runtimes(limit); - Ok(Json(workspace_api::ListResponse { + Ok(Json(server_api::ListResponse { workspace_id: api.config.workspace_id, limit, items: runtimes.items.into_iter().map(Into::into).collect(), @@ -13825,7 +14047,7 @@ async fn list_runtimes( async fn list_workers( State(api): State, -) -> ApiResult>> { +) -> ApiResult>> { workers_response(api).map(Json) } @@ -15020,7 +15242,7 @@ fn browser_worker_response_from_summary( worker, diagnostics: diagnostics .into_iter() - .map(workspace_api::Diagnostic::from) + .map(server_api::Diagnostic::from) .collect(), }) } @@ -15167,7 +15389,7 @@ async fn post_companion_cancel( #[derive(Debug, Serialize)] struct WorkerShowProjection { #[serde(flatten)] - worker: workspace_api::WorkerSummary, + worker: server_api::WorkerSummary, updated_at: String, } @@ -15221,7 +15443,7 @@ async fn get_runtime_worker( async fn restore_runtime_worker( State(api): State, AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, -) -> ApiResult> { +) -> ApiResult> { let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?; let result = api.restore_workspace_worker(&worker)?; let projected_worker = if let Some(worker) = result.worker.as_ref() { @@ -15237,12 +15459,12 @@ async fn restore_runtime_worker( } else { None }; - Ok(Json(workspace_api::WorkerRestoreResponse { + Ok(Json(server_api::WorkerRestoreResponse { workspace_id: api.workspace_id().to_string(), runtime_id: runtime_id.clone(), worker_id: worker_id.clone(), - result: workspace_api::WorkerRestoreResult { - state: result.state.into(), + result: server_api::WorkerRestoreResult { + state: result.state, worker: projected_worker, diagnostics: result.diagnostics.into_iter().map(Into::into).collect(), }, @@ -15301,7 +15523,7 @@ async fn list_runtime_workers( State(api): State, AxumPath(runtime_id): AxumPath, Query(query): Query, -) -> ApiResult>> { +) -> ApiResult>> { let limit = api.config.max_records.min(200); let (runtime_workers, source) = match query.status { Some(RuntimeWorkersStatusFilter::Stopped) => ( @@ -15318,7 +15540,7 @@ async fn list_runtime_workers( ), }; let items = project_observed_workspace_workers(&api, runtime_workers.items)?; - Ok(Json(workspace_api::ListResponse { + Ok(Json(server_api::ListResponse { workspace_id: api.workspace_id().to_string(), limit, items, @@ -16037,11 +16259,9 @@ pub(crate) fn authorize_browser_worker_method( protocol::Method::Notify { notification_request_id, message, - auto_run, } => Ok(protocol::Method::NotifyTracked { notification_request_id, message, - auto_run, source: source.clone(), }), protocol::Method::SubmitTracked { .. } | protocol::Method::NotifyTracked { .. } => { @@ -16497,14 +16717,14 @@ fn protocol_error_event(message: impl Into) -> protocol::Event { async fn list_host_workers( State(api): State, AxumPath(host_id): AxumPath, -) -> ApiResult>> { +) -> ApiResult>> { let limit = api.config.max_records.min(200); let runtime_workers = api .runtime .list_workers_for_host(&host_id, limit) .map_err(|err| err.into_error())?; let items = project_observed_workspace_workers(&api, runtime_workers.items)?; - Ok(Json(workspace_api::ListResponse { + Ok(Json(server_api::ListResponse { workspace_id: api.workspace_id().to_string(), limit, items, @@ -16520,7 +16740,7 @@ async fn list_host_workers( fn project_workspace_worker( api: &WorkspaceApi, summary: WorkerSummary, -) -> ApiResult { +) -> ApiResult { let resource_key = api .store .resource_key( @@ -16546,14 +16766,14 @@ fn project_workspace_worker( Ok(workspace_worker_summary( summary, resource_key, - working_directory, + working_directory.map(Into::into), )) } fn project_observed_workspace_workers( api: &WorkspaceApi, workers: Vec, -) -> ApiResult> { +) -> ApiResult> { let workdirs = api .store .list_workdir_registry(&api.config.workspace_id, 500)?; @@ -16573,66 +16793,41 @@ fn project_observed_workspace_workers( fn workers_response( api: WorkspaceApi, -) -> ApiResult> { +) -> ApiResult> { let limit = api.config.max_records.min(200); - let runtime_workers = api.runtime.list_workers(limit); - let mut observed = std::collections::BTreeMap::new(); - for worker in &runtime_workers.items { - let _ = sync_worker_observation(&api, worker); - observed.insert(worker.worker.clone(), worker.clone()); - } - let mut diagnostics = runtime_workers.diagnostics; - let worker_records = api + let (_, projections) = api .store - .list_worker_registry(&api.config.workspace_id, limit)?; + .worker_registry_projection_snapshot(&api.config.workspace_id, limit)?; let workdir_records = api .store .list_workdir_registry(&api.config.workspace_id, 500)?; let mut items = Vec::new(); - for record in worker_records { - if !observed.contains_key(&record.worker) { - match api.runtime.worker(&record.worker) { - Ok(worker) => { - let _ = sync_worker_observation(&api, &worker); - observed.insert(record.worker.clone(), worker); - } - Err(RuntimeRegistryError::UnknownWorker { .. }) => {} - Err(error) => diagnostics.push(RuntimeDiagnostic { - code: "worker_detail_probe_failed".to_string(), - severity: DiagnosticSeverity::Info, - message: format!( - "Could not verify Worker {} on Runtime {}: {}", - record.worker.worker_id, - record.worker.runtime_id, - sanitize_backend_error(&error.into_error().to_string()) - ), - }), - } - } + for projection in projections { let links = api .store - .list_worker_workdir_links(&api.config.workspace_id, &record.worker)?; + .list_worker_workdir_links(&api.config.workspace_id, &projection.registry.worker)?; + let observed = worker_summary_from_projection(&projection); let summary = merge_worker_registry_projection( - observed.get(&record.worker), - &record, + observed.as_ref(), + &projection.registry, links, &workdir_records, ); items.push(project_workspace_worker(&api, summary)?); } - Ok(workspace_api::ListResponse { + Ok(server_api::ListResponse { workspace_id: api.config.workspace_id, limit, items, - source: "backend_worker_registry".to_string(), - diagnostics: diagnostics.into_iter().map(Into::into).collect(), + source: "backend_worker_registry_projection".to_string(), + diagnostics: Vec::new(), }) } async fn workspace_runtime_resources_response( api: &WorkspaceApi, workspace_id: &str, -) -> ApiResult> { +) -> ApiResult> { let limit = api.config.max_records.min(200); let runtimes = api.runtime.list_runtimes(limit); let bindings = api @@ -16657,7 +16852,7 @@ async fn workspace_runtime_resources_response( .iter() .find(|binding| binding.runtime_id == runtime.runtime_id); let built_in = runtime.runtime_id == EMBEDDED_WORKER_RUNTIME_ID; - let mut runtime: workspace_api::RuntimeSummary = runtime.into(); + let mut runtime: server_api::RuntimeSummary = runtime.into(); if binding.is_some_and(|binding| binding.state != StoredRuntimeBindingState::Verified) { runtime.worker_creation_available = false; } @@ -16686,16 +16881,16 @@ async fn workspace_runtime_resources_response( continue; } items.push(WorkspaceRuntimeResource { - runtime: workspace_api::RuntimeSummary { + runtime: server_api::RuntimeSummary { runtime_id: binding.runtime_id.clone(), label: binding.display_name.clone(), kind: "remote_http".to_string(), status: "unavailable".to_string(), - source: workspace_api::RuntimeSourceSummary { - kind: workspace_api::RuntimeSourceKind::RemoteHttp, - status: workspace_api::RuntimeSourceStatus::Reserved, + source: server_api::RuntimeSourceSummary { + kind: server_api::RuntimeSourceKind::RemoteHttp, + status: server_api::RuntimeSourceStatus::Reserved, identity_authority: - workspace_api::RuntimeIdentityAuthority::ServerRuntimeConfiguration, + server_api::RuntimeIdentityAuthority::ServerRuntimeConfiguration, note: "The registered Runtime is not present in the active Runtime registry." .to_string(), }, @@ -16726,7 +16921,7 @@ async fn workspace_runtime_resources_response( }); } - Ok(workspace_api::ListResponse { + Ok(server_api::ListResponse { workspace_id: workspace_id.to_string(), limit, items, @@ -16780,15 +16975,15 @@ fn runtime_binding_summary( fn runtime_verification_summary( verification: &crate::store::WorkspaceRuntimeVerificationEvidence, -) -> Option { +) -> Option { let last_outcome = match verification.last_outcome.as_str() { - "verified" => workspace_api::RuntimeVerificationOutcome::Verified, - "challenge_issued" => workspace_api::RuntimeVerificationOutcome::ChallengeIssued, - "verification_failed" => workspace_api::RuntimeVerificationOutcome::VerificationFailed, - "connectivity_failed" => workspace_api::RuntimeVerificationOutcome::ConnectivityFailed, + "verified" => server_api::RuntimeVerificationOutcome::Verified, + "challenge_issued" => server_api::RuntimeVerificationOutcome::ChallengeIssued, + "verification_failed" => server_api::RuntimeVerificationOutcome::VerificationFailed, + "connectivity_failed" => server_api::RuntimeVerificationOutcome::ConnectivityFailed, _ => return None, }; - Some(workspace_api::RuntimeVerificationEvidenceSummary { + Some(server_api::RuntimeVerificationEvidenceSummary { verified_at: verification.verified_at.clone(), last_checked_at: verification.checked_at.clone(), last_outcome, @@ -16817,16 +17012,16 @@ async fn workspace_runtime_detail( .find(|resource| resource.runtime.runtime_id == runtime_id); if resource.is_none() { resource = binding.as_ref().map(|binding| WorkspaceRuntimeResource { - runtime: workspace_api::RuntimeSummary { + runtime: server_api::RuntimeSummary { runtime_id: binding.runtime_id.clone(), label: binding.display_name.clone(), kind: "remote_http".to_string(), status: "unavailable".to_string(), - source: workspace_api::RuntimeSourceSummary { - kind: workspace_api::RuntimeSourceKind::RemoteHttp, - status: workspace_api::RuntimeSourceStatus::Reserved, + source: server_api::RuntimeSourceSummary { + kind: server_api::RuntimeSourceKind::RemoteHttp, + status: server_api::RuntimeSourceStatus::Reserved, identity_authority: - workspace_api::RuntimeIdentityAuthority::ServerRuntimeConfiguration, + server_api::RuntimeIdentityAuthority::ServerRuntimeConfiguration, note: "The Runtime trust binding is not active in the Runtime registry." .to_string(), }, @@ -17178,7 +17373,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> ApiResult ApiResult ApiResult protocol::subscription::SubscriptionWorkerState::Running, + "paused" => protocol::subscription::SubscriptionWorkerState::Paused, + "stopped" => protocol::subscription::SubscriptionWorkerState::Stopped, + _ => protocol::subscription::SubscriptionWorkerState::Idle, + }; + let observation = protocol::subscription::SubscriptionWorker { + worker_id, + runtime_id: Some(worker.worker.runtime_id.clone()), + resource_key: None, + availability: protocol::subscription::SubscriptionWorkerAvailability::Observed, + subject_revision: 0, + worker_state: worker.worker_state.clone(), + state, + has_running_internal_workers: false, + workspace_id: worker.workspace.workspace_id.clone(), + display_name: Some(worker.display_name.clone()), + profile: worker.profile.clone(), + repository_id: None, + repository_key: None, + working_directory_id: None, + }; + api.worker_projection + .seed_observation( + worker.worker.runtime_id.as_str(), + &observation, + worker + .last_seen_at + .as_deref() + .unwrap_or(record.updated_at.as_str()), + ) + .map_err(ApiError::from)?; + } else { + api.worker_projection + .publish_catalog_change(&worker_ref) + .map_err(ApiError::from)?; + } Ok(api .store .get_worker_registry(&api.config.workspace_id, &worker_ref)? @@ -17394,6 +17633,45 @@ fn worker_summary_from_registry(record: &WorkerRegistryRecord) -> WorkerSummary } } +fn worker_summary_from_projection( + projection: &crate::store::WorkerRegistryProjectionRecord, +) -> Option { + let observation = projection.observation.as_ref()?; + let mut summary = worker_summary_from_registry(&projection.registry); + let observed = observation.availability + == protocol::subscription::SubscriptionWorkerAvailability::Observed; + summary.state = if observed { + match observation.worker.state { + protocol::subscription::SubscriptionWorkerState::Idle => "idle", + protocol::subscription::SubscriptionWorkerState::Running => "running", + protocol::subscription::SubscriptionWorkerState::Paused => "paused", + protocol::subscription::SubscriptionWorkerState::Stopped => "stopped", + } + } else { + "unavailable" + } + .to_string(); + summary.worker_state = observation.worker.worker_state.clone(); + summary.last_seen_at = Some(observation.observed_at.clone()); + summary.capabilities.can_stop = observed + && observation.worker.state != protocol::subscription::SubscriptionWorkerState::Stopped; + summary.implementation.display_hint = if observed { + "Runtime-backed Worker".to_string() + } else { + "Worker observation unavailable".to_string() + }; + summary.diagnostics = if observed { + Vec::new() + } else { + vec![RuntimeDiagnostic { + code: "worker_observation_unavailable".to_string(), + severity: DiagnosticSeverity::Info, + message: "The Worker remains in the Backend catalog, but its Runtime observation is currently unavailable.".to_string(), + }] + }; + Some(summary) +} + fn merge_worker_registry_projection( live: Option<&WorkerSummary>, record: &WorkerRegistryRecord, @@ -17911,7 +18189,7 @@ fn authorize_repository_materialization_operation( request_fingerprint: &str, request: &mut WorkingDirectoryRequest, ) -> ApiResult<()> { - let context = if request.repository.source.kind == workspace_api::RepositorySourceKind::Ssh { + let context = if request.repository.source.kind == server_api::RepositorySourceKind::Ssh { if let ( Some(_credential_id), Some(_credential_revision), @@ -17936,8 +18214,8 @@ fn authorize_repository_materialization_operation( let primary_host_trust_revision = primary_lease.host_trust_revision; let known_hosts_entry = primary_lease.known_hosts_entry.clone(); let access = match access_mode { - "read_only" => workspace_api::RepositoryAccessMode::ReadOnly, - "read_write" => workspace_api::RepositoryAccessMode::ReadWrite, + "read_only" => server_api::RepositoryAccessMode::ReadOnly, + "read_write" => server_api::RepositoryAccessMode::ReadWrite, _ => { return Err(settings_bad_request( "working_directory_repository_access_invalid", @@ -18069,8 +18347,8 @@ fn authorize_repository_materialization_operation( &ssh.host_trust_id, ssh.host_trust_revision, match ssh.access { - workspace_api::RepositoryAccessMode::ReadOnly => "read_only", - workspace_api::RepositoryAccessMode::ReadWrite => "read_write", + server_api::RepositoryAccessMode::ReadOnly => "read_only", + server_api::RepositoryAccessMode::ReadWrite => "read_write", }, &credential_candidates, &now_registry_timestamp(), @@ -18171,8 +18449,8 @@ fn repository_ssh_lease_candidates_from_operation( .repository_access_mode .as_deref() .map(|access_mode| match access_mode { - "read_only" => Ok(workspace_api::RepositoryAccessMode::ReadOnly), - "read_write" => Ok(workspace_api::RepositoryAccessMode::ReadWrite), + "read_only" => Ok(server_api::RepositoryAccessMode::ReadOnly), + "read_write" => Ok(server_api::RepositoryAccessMode::ReadWrite), _other => Err(settings_bad_request( "working_directory_repository_access_snapshot_invalid", "persisted Repository access mode is invalid", @@ -18229,7 +18507,7 @@ fn authorize_repository_materialization( .find(|repository| repository.id == request.repository.id) .map(|repository| repository.repository_key.as_str()) .ok_or_else(|| Error::UnknownRepository(request.repository.id.clone()))?; - let ssh = if request.repository.source.kind == workspace_api::RepositorySourceKind::Ssh { + let ssh = if request.repository.source.kind == server_api::RepositorySourceKind::Ssh { let binding = match projection .bindings .iter() @@ -18359,7 +18637,7 @@ fn repository_access_request_for_workdir( )); } let repository = api.require_configured_workspace_repository(&record.repository_id)?; - if repository.source.kind != workspace_api::RepositorySourceKind::Ssh { + if repository.source.kind != server_api::RepositorySourceKind::Ssh { return Ok(None); } let projection = active_repository_access_projection(api, &api.config.workspace_id)?; @@ -18767,127 +19045,142 @@ impl ApiError { fn with_diagnostics(error: Error, diagnostics: Vec) -> Self { Self { error, diagnostics } } + + fn into_repository_api_error(self) -> server_api::RepositoryApiError { + let status = api_error_status(&self.error); + let message = api_error_response_message(&self.error); + server_api::RepositoryApiError::new( + status.as_u16(), + status.canonical_reason().unwrap_or("error"), + message, + working_directory_diagnostics(self.diagnostics), + ) + } +} + +fn api_error_status(error: &Error) -> StatusCode { + match error { + Error::BrowserReopenConfirmationRequired | Error::WorkspacePermissionDenied(_) => { + StatusCode::FORBIDDEN + } + Error::TicketAssignmentConflict(_) + | Error::WorkdirAttachmentConflict(_) + | Error::WorkspaceConfigConflict(_) + | Error::RuntimeBindingConflict(_) + | Error::RuntimeBindingRevisionConflict { .. } + | Error::RuntimeBindingFingerprintConflict { .. } + | Error::RepositoryConflict(_) => StatusCode::CONFLICT, + Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST, + Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => { + StatusCode::BAD_REQUEST + } + Error::Ticket(ticket::TicketError::NotFound(_)) + | Error::MergeRequest(merge_request::MergeRequestError::NotFound) => StatusCode::NOT_FOUND, + Error::MergeRequest(merge_request::MergeRequestError::Validation(_)) => { + StatusCode::BAD_REQUEST + } + Error::MergeRequest(_) => StatusCode::CONFLICT, + Error::Ticket( + ticket::TicketError::Ambiguous { .. } + | ticket::TicketError::Locked { .. } + | ticket::TicketError::Conflict(_), + ) => StatusCode::CONFLICT, + Error::Ticket( + ticket::TicketError::InvalidPathComponent(_) + | ticket::TicketError::PathEscapesRoot { .. }, + ) => StatusCode::BAD_REQUEST, + Error::InvalidRecordId(_) + | Error::MissingFrontmatter(_) + | Error::UnknownHost(_) + | Error::UnknownRuntime(_) + | Error::UnknownWorker { .. } + | Error::UnknownRepository(_) + | Error::RuntimeBindingNotFound { .. } + | Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND, + Error::RuntimeOperationFailed { code, .. } if code == "skill_not_found" => { + StatusCode::NOT_FOUND + } + Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED, + Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => { + StatusCode::NOT_FOUND + } + Error::RuntimeOperationFailed { code, .. } if code == "repository_provider_unsupported" => { + StatusCode::BAD_REQUEST + } + Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_auth_failed" => { + StatusCode::UNAUTHORIZED + } + Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_timeout" => { + StatusCode::GATEWAY_TIMEOUT + } + Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_unsupported" => { + StatusCode::NOT_IMPLEMENTED + } + Error::RuntimeOperationFailed { code, .. } + if code == "profile_registry_revision_conflict" + || code == "profile_source_revision_conflict" + || code == "workspace_metadata_revision_conflict" + || code == "workspace_cleanup_plan_stale" + || code == "workspace_cleanup_worker_blocked" + || code == "workspace_cleanup_workdir_blocked" + || code == "workspace_cleanup_worker_pinned" => + { + StatusCode::CONFLICT + } + Error::RuntimeOperationFailed { code, .. } + if code == "unknown_profile_source" + || code == "unknown_profile_selector" + || code == "unknown_objective" => + { + StatusCode::NOT_FOUND + } + Error::RuntimeOperationFailed { code, .. } + if code == "workspace_display_name_invalid" || code.starts_with("profile_") => + { + StatusCode::BAD_REQUEST + } + Error::RuntimeOperationFailed { code, .. } if code.ends_with("_blocked") => { + StatusCode::CONFLICT + } + Error::RuntimeOperationFailed { code, .. } + if code.starts_with("workspace_settings_") + || code.starts_with("invalid_") + || code.starts_with("unsupported_worker_profile") + || code.starts_with("working_directory_") + || code.starts_with("workspace_cleanup_") + || code == "default_runtime_not_configured" + || code == "workspace_worker_workdir_required" + || code.ends_with("_already_exists") + || code.ends_with("_not_config_managed") + || code.ends_with("_unsupported") => + { + StatusCode::BAD_REQUEST + } + Error::RuntimeOperationFailed { code, .. } + if code == "runtime_capacity_unavailable" + || code == "runtime_workdir_capacity_unavailable" => + { + StatusCode::SERVICE_UNAVAILABLE + } + Error::WorkspaceSigningIdentity { .. } => StatusCode::SERVICE_UNAVAILABLE, + Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } +} + +fn api_error_response_message(error: &Error) -> String { + match error { + Error::RuntimeOperationFailed { code, message, .. } => { + format!("{code}: {}", sanitize_backend_error(message)) + } + _ => sanitize_backend_error(&error.to_string()), + } } impl IntoResponse for ApiError { fn into_response(self) -> Response { - let status = match &self.error { - Error::BrowserReopenConfirmationRequired | Error::WorkspacePermissionDenied(_) => { - StatusCode::FORBIDDEN - } - Error::TicketAssignmentConflict(_) - | Error::WorkdirAttachmentConflict(_) - | Error::WorkspaceConfigConflict(_) - | Error::RuntimeBindingConflict(_) - | Error::RuntimeBindingRevisionConflict { .. } - | Error::RuntimeBindingFingerprintConflict { .. } - | Error::RepositoryConflict(_) => StatusCode::CONFLICT, - Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST, - Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => { - StatusCode::BAD_REQUEST - } - Error::Ticket(ticket::TicketError::NotFound(_)) - | Error::MergeRequest(merge_request::MergeRequestError::NotFound) => { - StatusCode::NOT_FOUND - } - Error::MergeRequest(merge_request::MergeRequestError::Validation(_)) => { - StatusCode::BAD_REQUEST - } - Error::MergeRequest(_) => StatusCode::CONFLICT, - Error::Ticket( - ticket::TicketError::Ambiguous { .. } - | ticket::TicketError::Locked { .. } - | ticket::TicketError::Conflict(_), - ) => StatusCode::CONFLICT, - Error::Ticket( - ticket::TicketError::InvalidPathComponent(_) - | ticket::TicketError::PathEscapesRoot { .. }, - ) => StatusCode::BAD_REQUEST, - Error::InvalidRecordId(_) - | Error::MissingFrontmatter(_) - | Error::UnknownHost(_) - | Error::UnknownRuntime(_) - | Error::UnknownWorker { .. } - | Error::UnknownRepository(_) - | Error::RuntimeBindingNotFound { .. } - | Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND, - Error::RuntimeOperationFailed { code, .. } if code == "skill_not_found" => { - StatusCode::NOT_FOUND - } - Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED, - Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => { - StatusCode::NOT_FOUND - } - Error::RuntimeOperationFailed { code, .. } - if code == "repository_provider_unsupported" => - { - StatusCode::BAD_REQUEST - } - Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_auth_failed" => { - StatusCode::UNAUTHORIZED - } - Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_timeout" => { - StatusCode::GATEWAY_TIMEOUT - } - Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_unsupported" => { - StatusCode::NOT_IMPLEMENTED - } - Error::RuntimeOperationFailed { code, .. } - if code == "profile_registry_revision_conflict" - || code == "profile_source_revision_conflict" - || code == "workspace_metadata_revision_conflict" - || code == "workspace_cleanup_plan_stale" - || code == "workspace_cleanup_worker_blocked" - || code == "workspace_cleanup_workdir_blocked" - || code == "workspace_cleanup_worker_pinned" => - { - StatusCode::CONFLICT - } - Error::RuntimeOperationFailed { code, .. } - if code == "unknown_profile_source" - || code == "unknown_profile_selector" - || code == "unknown_objective" => - { - StatusCode::NOT_FOUND - } - Error::RuntimeOperationFailed { code, .. } - if code == "workspace_display_name_invalid" || code.starts_with("profile_") => - { - StatusCode::BAD_REQUEST - } - Error::RuntimeOperationFailed { code, .. } if code.ends_with("_blocked") => { - StatusCode::CONFLICT - } - Error::RuntimeOperationFailed { code, .. } - if code.starts_with("workspace_settings_") - || code.starts_with("invalid_") - || code.starts_with("unsupported_worker_profile") - || code.starts_with("working_directory_") - || code.starts_with("workspace_cleanup_") - || code == "default_runtime_not_configured" - || code == "workspace_worker_workdir_required" - || code.ends_with("_already_exists") - || code.ends_with("_not_config_managed") - || code.ends_with("_unsupported") => - { - StatusCode::BAD_REQUEST - } - Error::RuntimeOperationFailed { code, .. } - if code == "runtime_capacity_unavailable" - || code == "runtime_workdir_capacity_unavailable" => - { - StatusCode::SERVICE_UNAVAILABLE - } - Error::WorkspaceSigningIdentity { .. } => StatusCode::SERVICE_UNAVAILABLE, - Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY, - _ => StatusCode::INTERNAL_SERVER_ERROR, - }; - let response_message = match &self.error { - Error::RuntimeOperationFailed { code, message, .. } => { - format!("{code}: {}", sanitize_backend_error(message)) - } - _ => sanitize_backend_error(&self.error.to_string()), - }; + let status = api_error_status(&self.error); + let response_message = api_error_response_message(&self.error); let log = ApiErrorLog { kind: self .diagnostics @@ -18950,6 +19243,46 @@ mod tests { SqliteWorkspaceStore, UserRecord, WorkspaceRecord, WorkspaceRuntimeBinding, }; + #[derive(Clone)] + struct TestBearerAuthorizer(&'static str); + + impl server_api::client_support::RequestAuthorizer for TestBearerAuthorizer { + fn authorize( + &self, + _request: server_api::client_support::AuthorizerRequest<'_>, + ) -> std::result::Result< + axum::http::HeaderMap, + server_api::client_support::AuthorizationError, + > { + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::AUTHORIZATION, + format!("Bearer {}", self.0) + .parse() + .map_err(|_| server_api::client_support::AuthorizationError::new())?, + ); + Ok(headers) + } + } + + #[tokio::test] + async fn repository_conflict_rejections_use_the_typed_error_contract() { + let response = repository_api_rejection( + "/api/w/workspace-test/repositories", + StatusCode::CONFLICT, + "Workspace is deleting and no longer accepts mutations", + ); + assert_eq!(response.status(), StatusCode::CONFLICT); + let body: server_api::RepositoryApiError = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(body.error, "Conflict"); + assert_eq!( + body.message, + "Workspace is deleting and no longer accepts mutations" + ); + } + #[test] fn backend_resource_timeout_maps_to_gateway_timeout() { assert_eq!( @@ -19168,7 +19501,8 @@ mod tests { current_ref: Some("abc123".to_string()), current_tree: None, observed_at_epoch_seconds: Some(1_767_225_600), - materializer_kind: workspace_api::WorkingDirectoryMaterializerKind::RuntimeGitClone, + materializer_kind: + workdir::workspace::WorkingDirectoryMaterializerKind::RuntimeGitClone, cleanup_target: None, status: worker_runtime::catalog::WorkingDirectoryStatusKind::Active, cleanliness: Some("clean".to_string()), @@ -20046,11 +20380,11 @@ mod tests { "owner-account", ) .unwrap(); - let binding = workspace_api::RepositorySshAccessBinding { + let binding = server_api::RepositorySshAccessBinding { repository_key: "test-repository".to_string(), credential_id: specific.credential_id.clone(), host_trust_id: host_trust.host_trust_id, - access: workspace_api::RepositoryAccessMode::ReadOnly, + access: server_api::RepositoryAccessMode::ReadOnly, }; let primary = api .repository_secrets @@ -20074,8 +20408,8 @@ mod tests { candidates[1].known_hosts_entry ); - let source = workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::Ssh, + let source = server_api::RepositorySource { + kind: server_api::RepositorySourceKind::Ssh, uri: "ssh://git@example.test/org/repository.git".to_string(), }; let source_fingerprint = repository_source_fingerprint(&source); @@ -20157,7 +20491,7 @@ mod tests { .is_err() ); - let default_binding = workspace_api::RepositorySshAccessBinding { + let default_binding = server_api::RepositorySshAccessBinding { credential_id: crate::repository_access::WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID .to_string(), ..binding @@ -20190,14 +20524,14 @@ mod tests { repository_key: "foreign".to_string(), kind: "git".to_string(), provider: Some("git".to_string()), - source: workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::LocalPath, + source: server_api::RepositorySource { + kind: server_api::RepositorySourceKind::LocalPath, uri: dir.path().join("foreign").display().to_string(), }, default_ref: Some("HEAD".to_string()), source_revision: 1, source_fingerprint: "sha256:test".to_string(), - observed_status: workspace_api::RepositoryObservedStatus::Unverified, + observed_status: server_api::RepositoryObservedStatus::Unverified, observed_at: None, created_at: "1".to_string(), updated_at: "1".to_string(), @@ -20313,7 +20647,7 @@ mod tests { ], host_trust_id: "host-trust-1".to_string(), host_trust_revision: 1, - access: workspace_api::RepositoryAccessMode::ReadOnly, + access: server_api::RepositoryAccessMode::ReadOnly, expires_at_epoch_seconds: u64::MAX, repository_id: working_directory.repository.id.clone(), repository_source_fingerprint: working_directory @@ -21377,6 +21711,7 @@ mod tests { .await .unwrap(); drop(api); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; let restored_config = test_server_config(&root); let restored_store = @@ -21510,7 +21845,7 @@ mod tests { State(api), Extension(actor), Json(CreateRemoteRuntimeRequest { - public_bundle: workspace_api::RuntimePublicIdentityBundle { + public_bundle: server_api::RuntimePublicIdentityBundle { identity_id: "verified-runtime".to_string(), public_key: replacement.public_key, }, @@ -21547,7 +21882,7 @@ mod tests { ); let runtime_identity = RuntimeIdentityMaterial::generate("configured-runtime").unwrap(); let request = CreateRemoteRuntimeRequest { - public_bundle: workspace_api::RuntimePublicIdentityBundle { + public_bundle: server_api::RuntimePublicIdentityBundle { identity_id: "configured-runtime".to_string(), public_key: runtime_identity.public_key, }, @@ -21650,7 +21985,7 @@ mod tests { State(api.clone()), Extension(test_owner_actor()), Json(CreateRemoteRuntimeRequest { - public_bundle: workspace_api::RuntimePublicIdentityBundle { + public_bundle: server_api::RuntimePublicIdentityBundle { identity_id: "unknown-runtime".to_string(), public_key: unknown_identity.public_key, }, @@ -21686,7 +22021,7 @@ mod tests { async fn runtime_connection_request_validation_bounds_browser_input() { let identity = RuntimeIdentityMaterial::generate("team-runtime_1").unwrap(); let ok = CreateRemoteRuntimeRequest { - public_bundle: workspace_api::RuntimePublicIdentityBundle { + public_bundle: server_api::RuntimePublicIdentityBundle { identity_id: "team-runtime_1".to_string(), public_key: identity.public_key, }, @@ -22036,8 +22371,8 @@ mod tests { created_at: "1".to_string(), updated_at: "1".to_string(), }; - let source = workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::Https, + let source = server_api::RepositorySource { + kind: server_api::RepositorySourceKind::Https, uri: "https://example.test/org/repository.git".to_string(), }; let repositories = vec![RepositoryRecord { @@ -22050,7 +22385,7 @@ mod tests { source, default_ref: Some("main".to_string()), source_revision: 1, - observed_status: workspace_api::RepositoryObservedStatus::Unverified, + observed_status: server_api::RepositoryObservedStatus::Unverified, observed_at: None, created_at: "1".to_string(), updated_at: "1".to_string(), @@ -22062,7 +22397,7 @@ mod tests { assert!(scoped.repositories[0].path.is_none()); assert_eq!( scoped.repositories[0].source.kind, - workspace_api::RepositorySourceKind::Https + server_api::RepositorySourceKind::Https ); assert_eq!( scoped.workspace_execution_root, @@ -22087,8 +22422,8 @@ mod tests { created_at: "1".to_string(), updated_at: "1".to_string(), }; - let source = workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::Https, + let source = server_api::RepositorySource { + kind: server_api::RepositorySourceKind::Https, uri: "https://example.test/org/repository.git".to_string(), }; let repositories = vec![RepositoryRecord { @@ -22101,7 +22436,7 @@ mod tests { source, default_ref: Some("main".to_string()), source_revision: 1, - observed_status: workspace_api::RepositoryObservedStatus::Unverified, + observed_status: server_api::RepositoryObservedStatus::Unverified, observed_at: None, created_at: "1".to_string(), updated_at: "1".to_string(), @@ -22124,8 +22459,8 @@ mod tests { .with_embedded_runtime_store_root(store_root); config.database_path = workspace_root.join(".test-yoi-server.db"); config.backend_base_url = Some("http://127.0.0.1:8787".to_string()); - let source = workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::LocalPath, + let source = server_api::RepositorySource { + kind: server_api::RepositorySourceKind::LocalPath, uri: workspace_root.display().to_string(), }; config.repositories = vec![ConfiguredRepository { @@ -22135,7 +22470,7 @@ mod tests { source_fingerprint: crate::repository_source::repository_source_fingerprint(&source), source, source_revision: 1, - observed_status: workspace_api::RepositoryObservedStatus::Unverified, + observed_status: server_api::RepositoryObservedStatus::Unverified, observed_at: None, path: Some(workspace_root), default_selector: Some("HEAD".to_string()), @@ -22288,8 +22623,8 @@ mod tests { .unwrap(); assert_eq!(persisted.source, config.repositories[0].source); - let changed_source = workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::LocalPath, + let changed_source = server_api::RepositorySource { + kind: server_api::RepositorySourceKind::LocalPath, uri: dir.path().join("different-source").display().to_string(), }; config.repositories[0].source_fingerprint = @@ -22538,7 +22873,7 @@ mod tests { let workspace_body = to_bytes(authenticated_legacy.into_body(), usize::MAX) .await .unwrap(); - let typed_workspace: workspace_api::WorkspaceResponse = + let typed_workspace: server_api::WorkspaceResponse = serde_json::from_slice(&workspace_body).unwrap(); assert!(typed_workspace.permissions.manage_repositories); @@ -22568,7 +22903,7 @@ mod tests { let catalog_body = to_bytes(authenticated_catalog.into_body(), usize::MAX) .await .unwrap(); - let typed_catalog: workspace_api::WorkspaceCatalogListResponse = + let typed_catalog: server_api::WorkspaceCatalogListResponse = serde_json::from_slice(&catalog_body).unwrap(); assert_eq!( typed_catalog.0[0].workspace_id, @@ -22604,7 +22939,7 @@ mod tests { let created_body = to_bytes(created_response.into_body(), usize::MAX) .await .unwrap(); - let created: workspace_api::WorkspaceCreateResponse = + let created: server_api::WorkspaceCreateResponse = serde_json::from_slice(&created_body).unwrap(); assert_eq!(created.workspace.owner_account_id, "account-auth"); @@ -22752,6 +23087,122 @@ mod tests { .body(Body::from(body.to_string())) .unwrap() }; + let anonymous_repository = app + .clone() + .oneshot( + Request::builder() + .uri(&repositories_uri) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(anonymous_repository.status(), StatusCode::UNAUTHORIZED); + let anonymous_body: Value = serde_json::from_slice( + &to_bytes(anonymous_repository.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(anonymous_body["error"], "Unauthorized"); + + let unknown_field = app + .clone() + .oneshot(create_repository(serde_json::json!({ + "repository_key": "unknown-field", + "source": temp.path().join("unknown-field").display().to_string(), + "unexpected": true + }))) + .await + .unwrap(); + assert_eq!(unknown_field.status(), StatusCode::UNPROCESSABLE_ENTITY); + let unknown_field_body: Value = serde_json::from_slice( + &to_bytes(unknown_field.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(unknown_field_body["error"], "Unprocessable Entity"); + + let malformed = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri(&repositories_uri) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .header(ORIGIN, &expected_origin) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(malformed.status(), StatusCode::BAD_REQUEST); + assert!( + serde_json::from_slice::( + &to_bytes(malformed.into_body(), usize::MAX).await.unwrap() + ) + .is_ok() + ); + + let unsupported_media_type = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri(&repositories_uri) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .header(ORIGIN, &expected_origin) + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + unsupported_media_type.status(), + StatusCode::UNSUPPORTED_MEDIA_TYPE + ); + assert!( + serde_json::from_slice::( + &to_bytes(unsupported_media_type.into_body(), usize::MAX) + .await + .unwrap() + ) + .is_ok() + ); + + let oversized = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri(&repositories_uri) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .header(ORIGIN, &expected_origin) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(vec![b' '; 5 * 1024 * 1024 + 1])) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(oversized.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert!( + serde_json::from_slice::( + &to_bytes(oversized.into_body(), usize::MAX).await.unwrap() + ) + .is_ok() + ); + let created = app .clone() .oneshot(create_repository(repository_request.clone())) @@ -22841,6 +23292,93 @@ mod tests { .unwrap(); assert!(String::from_utf8_lossy(&listed_body).contains("documentation")); + let repository_detail_uri = format!("{repositories_uri}/documentation"); + let detailed = app + .clone() + .oneshot( + Request::builder() + .uri(repository_detail_uri) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(detailed.status(), StatusCode::OK); + let missing = app + .clone() + .oneshot( + Request::builder() + .uri(format!("{repositories_uri}/missing")) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let missing_status = missing.status(); + let missing_body = to_bytes(missing.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + missing_status, + StatusCode::NOT_FOUND, + "{}", + String::from_utf8_lossy(&missing_body) + ); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let api_base_url = format!("http://{}", listener.local_addr().unwrap()); + let network_app = app.clone(); + let server = tokio::spawn(async move { + axum::serve(listener, network_app).await.unwrap(); + }); + let generated = server_api::ServerApiClient::builder(&api_base_url) + .unwrap() + .authorizer(TestBearerAuthorizer("api-token-auth")) + .build() + .unwrap(); + let generated_workspace_id = workspace.workspace.workspace_id.clone(); + let generated_list = generated + .repository_list(generated_workspace_id.clone()) + .await + .unwrap(); + assert!( + generated_list + .items + .iter() + .any(|repository| repository.repository_key == "documentation") + ); + assert_eq!( + generated + .repository_detail(generated_workspace_id.clone(), "documentation".to_owned()) + .await + .unwrap() + .item + .repository_key, + "documentation" + ); + let generated_request = CreateWorkspaceRepositoryRequest { + repository_key: "generated-client".to_owned(), + source: "/srv/repos/generated-client".to_owned(), + default_ref: Some("main".to_owned()), + }; + let created = generated + .repository_create(generated_workspace_id.clone(), generated_request.clone()) + .await + .unwrap(); + assert!(!created.replayed); + let replayed = generated + .repository_create(generated_workspace_id, generated_request) + .await + .unwrap(); + assert!(replayed.replayed); + server.abort(); + let identity_uri = format!( "/api/w/{}/settings/signing-identity", workspace.workspace.workspace_id @@ -23009,6 +23547,7 @@ mod tests { .unwrap(); assert_eq!(mixed_auth_csrf_rejected.status(), StatusCode::FORBIDDEN); + let deleting_app = app.clone(); let csrf_accepted = app .oneshot( Request::builder() @@ -23026,6 +23565,48 @@ mod tests { .await .unwrap(); assert_ne!(csrf_accepted.status(), StatusCode::FORBIDDEN); + + let preflight = store + .workspace_deletion_preflight("account-auth", &workspace.workspace.workspace_id) + .unwrap(); + store + .reserve_workspace_deletion( + "account-auth", + &workspace.workspace.workspace_id, + &server_api::WorkspaceDeletionRequest { + operation_id: "repository-conflict-test".to_owned(), + expected_revision: preflight.expected_revision, + confirmation: preflight.display_name, + }, + ) + .unwrap(); + let deleting_conflict = deleting_app + .oneshot( + Request::builder() + .method(Method::POST) + .uri(&repositories_uri) + .header(axum::http::header::AUTHORIZATION, "Bearer api-token-auth") + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::json!({ + "repository_key": "blocked-by-deletion", + "source": temp.path().join("blocked-by-deletion").display().to_string() + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(deleting_conflict.status(), StatusCode::CONFLICT); + let deleting_body: server_api::RepositoryApiError = serde_json::from_slice( + &to_bytes(deleting_conflict.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(deleting_body.error, "Conflict"); + assert!(deleting_body.message.contains("deleting")); } #[tokio::test] @@ -25316,7 +25897,7 @@ mod tests { assert_eq!(retried_restore.worker_id, first_worker.worker.worker_id); assert_eq!( retried_restore.result.state, - workspace_api::WorkerOperationState::Accepted + server_api::WorkerRestoreState::Accepted ); let restored_assignment = api .store @@ -26232,7 +26813,7 @@ mod tests { scoped_update_workspace_memory_settings( State(api), AxumPath("workspace-foreign".to_string()), - Json(workspace_api::UpdateWorkspaceMemorySettingsRequest { + Json(server_api::UpdateWorkspaceMemorySettingsRequest { expected_revision: 1, language: "English".to_string(), }), @@ -27041,14 +27622,14 @@ mod tests { repository_key: repository_id.to_string(), kind: "git".to_string(), provider: Some("git".to_string()), - source: workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::LocalPath, + source: server_api::RepositorySource { + kind: server_api::RepositorySourceKind::LocalPath, uri: api.config.workspace_execution_root.display().to_string(), }, default_ref: Some("HEAD".to_string()), source_revision: 1, source_fingerprint: "sha256:test".to_string(), - observed_status: workspace_api::RepositoryObservedStatus::Unverified, + observed_status: server_api::RepositoryObservedStatus::Unverified, observed_at: None, created_at: "1".to_string(), updated_at: "1".to_string(), @@ -28414,8 +28995,8 @@ mod tests { worker_id: &str, name: &str, visibility: &str, - ) -> workspace_api::WorkerSummary { - workspace_api::WorkerSummary { + ) -> server_api::WorkerSummary { + server_api::WorkerSummary { runtime_id: "runtime-test".to_string(), worker_id: worker_id.to_string(), resource_key: key.to_string(), @@ -28425,7 +29006,7 @@ mod tests { profile: Some("builtin:coder".to_string()), singleton_key: None, tags: Vec::new(), - workspace: workspace_api::WorkerWorkspaceSummary { + workspace: server_api::WorkerWorkspaceSummary { visibility: visibility.to_string(), identity: "workspace-test".to_string(), workspace_id: Some(TEST_WORKSPACE_ID.to_string()), @@ -28435,11 +29016,11 @@ mod tests { last_seen_at: None, pinned: false, retention_state: "normal".to_string(), - implementation: workspace_api::WorkerImplementationSummary { + implementation: server_api::WorkerImplementationSummary { kind: "remote".to_string(), display_hint: "remote".to_string(), }, - capabilities: workspace_api::WorkerCapabilitySummary { + capabilities: server_api::WorkerCapabilitySummary { can_stop: true, can_spawn_followup: false, }, @@ -28512,7 +29093,7 @@ mod tests { assert!(before.is_empty()); let discovered = workspace_worker_discovery_page( - vec![workspace_api::WorkerSummary { + vec![server_api::WorkerSummary { runtime_id: target.runtime_id.clone(), worker_id: target.worker_id.clone(), resource_key: "W-2".to_string(), @@ -28522,7 +29103,7 @@ mod tests { profile: Some("builtin:coder".to_string()), singleton_key: None, tags: Vec::new(), - workspace: workspace_api::WorkerWorkspaceSummary { + workspace: server_api::WorkerWorkspaceSummary { visibility: "workspace_scoped".to_string(), identity: "workspace-test".to_string(), workspace_id: Some(TEST_WORKSPACE_ID.to_string()), @@ -28532,11 +29113,11 @@ mod tests { last_seen_at: None, pinned: false, retention_state: "normal".to_string(), - implementation: workspace_api::WorkerImplementationSummary { + implementation: server_api::WorkerImplementationSummary { kind: "remote".to_string(), display_hint: "remote".to_string(), }, - capabilities: workspace_api::WorkerCapabilitySummary { + capabilities: server_api::WorkerCapabilitySummary { can_stop: true, can_spawn_followup: false, }, @@ -29518,6 +30099,7 @@ mod tests { RuntimeRemovalOperationState::CleanupPending ); drop(api); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; let recovered = test_api(root.path()).await; let operation = recovered @@ -29589,12 +30171,8 @@ mod tests { let app = build_inner_router(api.clone()).layer(Extension(test_owner_actor())); let workers = get_json(app.clone(), "/api/workers").await; assert!( - workers["items"] - .as_array() - .unwrap() - .iter() - .any(|worker| worker["runtime_id"] == "busy-runtime"), - "expected remote runtime to report at least one worker, got {workers}" + workers["items"].as_array().unwrap().is_empty(), + "Runtime-only Workers must not be promoted into the Backend catalog: {workers}" ); let response = request_json( @@ -29968,7 +30546,7 @@ mod tests { let runtime_workers = get_json(app.clone(), "/api/runtimes/embedded-worker-runtime/workers").await; let runtime_workers = serde_json::from_value::< - workspace_api::ListResponse, + server_api::ListResponse, >(runtime_workers) .expect("Runtime-scoped Worker list must use the shared Workspace API contract"); assert!( @@ -30290,7 +30868,7 @@ mod tests { let workspace = get_json(app.clone(), "/api/workspace").await; assert_eq!(workspace["workspace_id"], TEST_WORKSPACE_ID); assert_eq!(workspace["display_name"], "Test Workspace"); - let typed_workspace: workspace_api::WorkspaceResponse = + let typed_workspace: server_api::WorkspaceResponse = serde_json::from_value(workspace.clone()).unwrap(); assert!(!typed_workspace.permissions.manage_repositories); assert_eq!(workspace["record_authority"], "server_db"); @@ -30451,7 +31029,7 @@ mod tests { let memory_document = get_json(app.clone(), &format!("/api/w/{TEST_WORKSPACE_ID}/memory")).await; - let _: workspace_api::MemoryDocumentResponse = + let _: server_api::MemoryDocumentResponse = serde_json::from_value(memory_document.clone()).unwrap(); assert_eq!(memory_document["created_at"], "2026-01-01T00:00:00Z"); assert_eq!(memory_document["updated_at"], "2026-01-02T00:00:00Z"); @@ -30469,7 +31047,7 @@ mod tests { &format!("/api/w/{TEST_WORKSPACE_ID}/memory/staging?limit=10"), ) .await; - let _: workspace_api::MemoryStagingListResponse = + let _: server_api::MemoryStagingListResponse = serde_json::from_value(memory_staging.clone()).unwrap(); assert_eq!( memory_staging["record_authority"], @@ -30483,7 +31061,7 @@ mod tests { ); let repositories = get_json(app.clone(), "/api/repositories").await; - let typed_repositories: workspace_api::RepositoryListResponse = + let typed_repositories: server_api::RepositoryListResponse = serde_json::from_value(repositories.clone()).unwrap(); assert_eq!( typed_repositories.items[0].repository_key, @@ -30502,7 +31080,7 @@ mod tests { ); let repository_detail = get_json(app.clone(), "/api/repositories/test-repository").await; - let _: workspace_api::RepositoryDetailResponse = + let _: server_api::RepositoryDetailResponse = serde_json::from_value(repository_detail.clone()).unwrap(); assert_eq!( repository_detail["item"]["repository_key"], @@ -30522,7 +31100,7 @@ mod tests { let repository_log = get_json(app.clone(), "/api/repositories/test-repository/log?limit=3").await; - let _: workspace_api::RepositoryLogResponse = + let _: server_api::RepositoryLogResponse = serde_json::from_value(repository_log.clone()).unwrap(); assert_eq!(repository_log["repository_key"], "test-repository"); assert!(repository_log.get("repository_id").is_none()); @@ -30853,6 +31431,7 @@ mod tests { tokio::time::sleep(std::time::Duration::from_millis(10)).await; } drop(api); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; let restored_store = test_control_store(&config); let restored = WorkspaceApi::new_with_execution_backend( @@ -30866,8 +31445,11 @@ mod tests { .runtime .worker(&worker_ref) .expect("restored worker"); - assert_eq!(restored_worker.state, "stopped"); - assert!(!restored_worker.capabilities.can_stop); + // The durable projection keeps an observer attached, so the restored Runtime + // catalog remains controllable while its failed execution is represented in + // `worker_state` rather than by removing the catalog Worker. + assert_eq!(restored_worker.state, "idle"); + assert!(restored_worker.capabilities.can_stop); let bundles = restored .runtime @@ -30970,13 +31552,13 @@ mod tests { id: "files".to_string(), repository_key: "files".to_string(), provider: "local_fs".to_string(), - source: workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::LocalPath, + source: server_api::RepositorySource { + kind: server_api::RepositorySourceKind::LocalPath, uri: root.path().display().to_string(), }, source_revision: 1, source_fingerprint: "sha256:test".to_string(), - observed_status: workspace_api::RepositoryObservedStatus::Unverified, + observed_status: server_api::RepositoryObservedStatus::Unverified, observed_at: None, path: Some(root.path().to_path_buf()), default_selector: None, @@ -31931,28 +32513,31 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); fn workspace_workdir_response_serializes_shared_occupied_contract() { let response = BrowserWorkingDirectoryListResponse { workspace_id: TEST_WORKSPACE_ID.to_string(), - items: vec![WorkingDirectorySummary { - working_directory_id: "wd-1".to_string(), - repository_key: "main".to_string(), - creation_selector: None, - creation_ref: None, - creation_tree: None, - current_selector: Some("work/ticket".to_string()), - current_ref: Some("abc123".to_string()), - current_tree: Some("tree123".to_string()), - observed_at_epoch_seconds: Some(1_777_777_777), - materializer_kind: MaterializerKind::RuntimeGitClone, - cleanup_target: None, - status: WorkingDirectoryStatusKind::Active, - cleanliness: Some("clean".to_string()), - primary_worker_id: None, - occupied_by: Some(WorkingDirectoryOccupancy { - runtime_id: "arcadia".to_string(), - worker_id: "worker-opaque-64".to_string(), - display_name: "Coder".to_string(), - linked_at: "2026-08-12T00:00:00Z".to_string(), - }), - }], + items: vec![ + WorkingDirectorySummary { + working_directory_id: "wd-1".to_string(), + repository_key: "main".to_string(), + creation_selector: None, + creation_ref: None, + creation_tree: None, + current_selector: Some("work/ticket".to_string()), + current_ref: Some("abc123".to_string()), + current_tree: Some("tree123".to_string()), + observed_at_epoch_seconds: Some(1_777_777_777), + materializer_kind: MaterializerKind::RuntimeGitClone, + cleanup_target: None, + status: WorkingDirectoryStatusKind::Active, + cleanliness: Some("clean".to_string()), + primary_worker_id: None, + occupied_by: Some(WorkingDirectoryOccupancy { + runtime_id: "arcadia".to_string(), + worker_id: "worker-opaque-64".to_string(), + display_name: "Coder".to_string(), + linked_at: "2026-08-12T00:00:00Z".to_string(), + }), + } + .into(), + ], diagnostics: vec![], }; diff --git a/crates/workspace-server/src/skills.rs b/crates/workspace-server/src/skills.rs index 917031084..fd3262816 100644 --- a/crates/workspace-server/src/skills.rs +++ b/crates/workspace-server/src/skills.rs @@ -4,12 +4,12 @@ use config_source::{ ConfigSchemaContribution, MarkdownDocumentProjection, VirtualPath, project_markdown_document, }; use serde::Deserialize; -use worker::skill::SkillActivationResponse; -use workspace_api::{ +use server_api::{ SKILL_CATALOG_AUTHORITY, SkillActivationStatus, SkillCatalogEntry, SkillCatalogResponse, SkillDetailResponse, SkillDiagnostic, SkillDiagnosticSeverity, SkillProjectionIdentity, SkillProjectionStatus, SkillProvenance, SkillResourceRef, SkillSourceKind, }; +use worker::skill::SkillActivationResponse; use crate::config_source::{ WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state, diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index b574ce0ae..1e0f0cf66 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -5,20 +5,23 @@ use std::time::Duration; use async_trait::async_trait; use flow::{CompiledFlowDefinition, FlowSourceKind, compile_flow_source}; +use protocol::subscription::{ + SubscriptionWorker, SubscriptionWorkerAvailability, SubscriptionWorkerId, +}; use rusqlite::backup::Backup; use rusqlite::{Connection, OpenFlags, OptionalExtension, TransactionBehavior, params}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use uuid::Uuid; +use server_api::{RepositoryObservedStatus, RepositorySource}; use worker_runtime::identity::{RuntimeWorkerRef, WorkerId}; -use workspace_api::{RepositoryObservedStatus, RepositorySource}; use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 61; +const LATEST_SCHEMA_VERSION: i64 = 62; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings"; const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit"; @@ -36,6 +39,51 @@ const WORKDIR_CREDENTIAL_CANDIDATE_SNAPSHOT_MIGRATION_NAME: &str = "Workdir create credential candidate snapshots"; const RUNTIME_REMOVAL_OPERATION_MIGRATION_NAME: &str = "guarded Runtime removal operations"; const REMOVE_WORKER_RUN_GENERATION_MIGRATION_NAME: &str = "remove obsolete Worker run generation"; +const WORKER_REGISTRY_PROJECTION_MIGRATION_NAME: &str = + "durable Runtime-backed Worker registry projection"; +const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#" +CREATE TABLE IF NOT EXISTS worker_registry_observations ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + availability TEXT NOT NULL CHECK (availability IN ('observed', 'unavailable')), + worker_json TEXT, + connection_generation INTEGER NOT NULL, + subject_revision INTEGER NOT NULL, + snapshot_revision INTEGER NOT NULL, + projection_revision INTEGER NOT NULL, + observed_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id, worker_id) + REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE +); +CREATE TABLE IF NOT EXISTS worker_registry_projection_cursors ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + connection_generation INTEGER NOT NULL, + snapshot_revision INTEGER NOT NULL, + PRIMARY KEY (workspace_id, runtime_id) +); +CREATE TABLE IF NOT EXISTS worker_registry_projection_revisions ( + workspace_id TEXT PRIMARY KEY, + revision INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS worker_registry_projection_removals ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + projection_revision INTEGER NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id) +); +CREATE TABLE IF NOT EXISTS worker_registry_projection_diagnostics ( + diagnostic_id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + reason TEXT NOT NULL, + observed_at TEXT NOT NULL +); +"#; const MIGRATIONS: &[Migration] = &[ Migration { @@ -93,6 +141,11 @@ const MIGRATIONS: &[Migration] = &[ name: REMOVE_WORKER_RUN_GENERATION_MIGRATION_NAME, apply: migrate_worker_run_generation_v60_to_v61, }, + Migration { + version: 62, + name: WORKER_REGISTRY_PROJECTION_MIGRATION_NAME, + apply: migrate_worker_registry_projection_v61_to_v62, + }, ]; #[derive(Clone, Copy)] @@ -488,6 +541,34 @@ pub struct WorkerRegistryRecord { pub updated_at: String, } +/// Runtime-backed observation joined to the durable Worker catalog. +/// +/// The Runtime payload remains intact so protocol projection is lossless. Ordering +/// metadata is persisted separately to reject stale reconnect snapshots and events. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkerRegistryObservationRecord { + pub worker: SubscriptionWorker, + pub availability: SubscriptionWorkerAvailability, + pub connection_generation: u64, + pub subject_revision: u64, + pub snapshot_revision: u64, + pub projection_revision: u64, + pub observed_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkerRegistryProjectionRecord { + pub registry: WorkerRegistryRecord, + pub resource_key: Option, + pub observation: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkerRegistryProjectionCommit { + pub revision: u64, + pub changed_workers: Vec, +} + /// Durable authority describing which Runtime Worker another Runtime Worker may /// discover and control. Revoked grants remain as audit evidence but are never /// returned by active-grant queries. @@ -1252,6 +1333,52 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { workspace_id: &str, limit: usize, ) -> Result>; + fn worker_registry_projection_snapshot( + &self, + workspace_id: &str, + limit: usize, + ) -> Result<(u64, Vec)>; + fn worker_registry_projection( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + ) -> Result>; + fn reconcile_worker_registry_snapshot( + &self, + workspace_id: &str, + runtime_id: &str, + connection_generation: u64, + snapshot_revision: u64, + workers: &[SubscriptionWorker], + observed_at: &str, + ) -> Result; + fn apply_worker_registry_observation( + &self, + workspace_id: &str, + runtime_id: &str, + connection_generation: u64, + worker: &SubscriptionWorker, + observed_at: &str, + ) -> Result; + fn mark_worker_registry_observation_unavailable( + &self, + workspace_id: &str, + runtime_id: &str, + connection_generation: u64, + worker_id: Option<&str>, + subject_revision: Option, + observed_at: &str, + ) -> Result; + fn publish_worker_registry_catalog_change( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + ) -> Result; + fn publish_worker_registry_removal( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + ) -> Result; fn update_worker_retention( &self, workspace_id: &str, @@ -5469,6 +5596,10 @@ impl ControlPlaneStore for SqliteWorkspaceStore { &record.worker.worker_id, &record.created_at, )?; + tx.execute( + "DELETE FROM worker_registry_projection_removals WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", + params![record.workspace_id, record.worker.runtime_id, record.worker.worker_id], + )?; tx.commit()?; Ok(()) }) @@ -5534,6 +5665,283 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn worker_registry_projection_snapshot( + &self, + workspace_id: &str, + limit: usize, + ) -> Result<(u64, Vec)> { + self.with_conn(|conn| { + let revision = current_worker_projection_revision(conn, workspace_id)?; + let mut statement = conn.prepare( + "SELECT runtime_id, worker_id FROM worker_registry WHERE workspace_id = ?1 ORDER BY updated_at DESC LIMIT ?2", + )?; + let identities = statement.query_map(params![workspace_id, limit as i64], |row| { + Ok(RuntimeWorkerRef { runtime_id: row.get(0)?, worker_id: row.get(1)? }) + })?.collect::, _>>()?; + let records = identities.iter() + .map(|worker| read_worker_registry_projection(conn, workspace_id, worker)) + .collect::>>()? + .into_iter().flatten().collect(); + Ok((revision, records)) + }) + } + + fn worker_registry_projection( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + ) -> Result> { + self.with_conn(|conn| read_worker_registry_projection(conn, workspace_id, worker)) + } + + fn reconcile_worker_registry_snapshot( + &self, + workspace_id: &str, + runtime_id: &str, + connection_generation: u64, + snapshot_revision: u64, + workers: &[SubscriptionWorker], + observed_at: &str, + ) -> Result { + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let (current_generation, current_snapshot) = + worker_projection_cursor(&tx, workspace_id, runtime_id)?; + if connection_generation < current_generation + || (connection_generation == current_generation + && snapshot_revision < current_snapshot) + { + let revision = current_worker_projection_revision(&tx, workspace_id)?; + tx.commit()?; + return Ok(WorkerRegistryProjectionCommit { + revision, + changed_workers: Vec::new(), + }); + } + upsert_worker_projection_cursor( + &tx, + workspace_id, + runtime_id, + connection_generation, + snapshot_revision, + )?; + let catalog = worker_registry_identities_for_runtime(&tx, workspace_id, runtime_id)?; + let runtime_workers = workers + .iter() + .map(|worker| (worker.worker_id.as_str(), worker)) + .collect::>(); + for worker in workers { + if !catalog + .iter() + .any(|item| item.worker_id == worker.worker_id.as_str()) + { + insert_worker_projection_orphan_diagnostic( + &tx, + workspace_id, + runtime_id, + worker.worker_id.as_str(), + "runtime_worker_without_backend_catalog", + observed_at, + )?; + } + } + let mut changed_workers = Vec::new(); + for identity in catalog { + let changed = if let Some(worker) = runtime_workers.get(identity.worker_id.as_str()) + { + upsert_worker_observation( + &tx, + workspace_id, + runtime_id, + connection_generation, + snapshot_revision, + worker, + SubscriptionWorkerAvailability::Observed, + observed_at, + )? + } else { + set_worker_observation_unavailable( + &tx, + workspace_id, + &identity, + connection_generation, + None, + snapshot_revision, + observed_at, + )? + }; + if changed { + changed_workers.push(identity); + } + } + let revision = commit_worker_projection_changes(&tx, workspace_id, &changed_workers)?; + tx.commit()?; + Ok(WorkerRegistryProjectionCommit { + revision, + changed_workers, + }) + }) + } + + fn apply_worker_registry_observation( + &self, + workspace_id: &str, + runtime_id: &str, + connection_generation: u64, + worker: &SubscriptionWorker, + observed_at: &str, + ) -> Result { + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let identity = RuntimeWorkerRef { + runtime_id: runtime_id.to_string(), + worker_id: worker.worker_id.to_string(), + }; + if !worker_registry_identity_exists(&tx, workspace_id, &identity)? { + insert_worker_projection_orphan_diagnostic( + &tx, + workspace_id, + runtime_id, + worker.worker_id.as_str(), + "runtime_worker_without_backend_catalog", + observed_at, + )?; + let revision = current_worker_projection_revision(&tx, workspace_id)?; + tx.commit()?; + return Ok(WorkerRegistryProjectionCommit { + revision, + changed_workers: Vec::new(), + }); + } + let changed = upsert_worker_observation( + &tx, + workspace_id, + runtime_id, + connection_generation, + 0, + worker, + SubscriptionWorkerAvailability::Observed, + observed_at, + )?; + let changed_workers = if changed { vec![identity] } else { Vec::new() }; + let revision = commit_worker_projection_changes(&tx, workspace_id, &changed_workers)?; + tx.commit()?; + Ok(WorkerRegistryProjectionCommit { + revision, + changed_workers, + }) + }) + } + + fn mark_worker_registry_observation_unavailable( + &self, + workspace_id: &str, + runtime_id: &str, + connection_generation: u64, + worker_id: Option<&str>, + subject_revision: Option, + observed_at: &str, + ) -> Result { + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let identities = if let Some(worker_id) = worker_id { + vec![RuntimeWorkerRef { + runtime_id: runtime_id.to_string(), + worker_id: worker_id.to_string(), + }] + } else { + worker_registry_identities_for_runtime(&tx, workspace_id, runtime_id)? + }; + let mut changed_workers = Vec::new(); + for identity in identities { + if !worker_registry_identity_exists(&tx, workspace_id, &identity)? { + insert_worker_projection_orphan_diagnostic( + &tx, + workspace_id, + runtime_id, + identity.worker_id.as_str(), + "runtime_worker_without_backend_catalog", + observed_at, + )?; + continue; + } + if set_worker_observation_unavailable( + &tx, + workspace_id, + &identity, + connection_generation, + subject_revision, + 0, + observed_at, + )? { + changed_workers.push(identity); + } + } + let revision = commit_worker_projection_changes(&tx, workspace_id, &changed_workers)?; + tx.commit()?; + Ok(WorkerRegistryProjectionCommit { + revision, + changed_workers, + }) + }) + } + + fn publish_worker_registry_catalog_change( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + ) -> Result { + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let changed_workers = if worker_registry_identity_exists(&tx, workspace_id, worker)? { + vec![worker.clone()] + } else { + Vec::new() + }; + let revision = commit_worker_projection_changes(&tx, workspace_id, &changed_workers)?; + tx.commit()?; + Ok(WorkerRegistryProjectionCommit { + revision, + changed_workers, + }) + }) + } + + fn publish_worker_registry_removal( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + ) -> Result { + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let published_revision = tx + .query_row( + "SELECT projection_revision FROM worker_registry_projection_removals WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", + params![workspace_id, worker.runtime_id, worker.worker_id], + |row| row.get::<_, i64>(0), + ) + .optional()?; + if let Some(revision) = published_revision { + tx.commit()?; + return Ok(WorkerRegistryProjectionCommit { + revision: revision.max(0) as u64, + changed_workers: Vec::new(), + }); + } + let changed_workers = vec![worker.clone()]; + let revision = commit_worker_projection_changes(&tx, workspace_id, &changed_workers)?; + tx.execute( + "INSERT INTO worker_registry_projection_removals (workspace_id, runtime_id, worker_id, projection_revision) VALUES (?1, ?2, ?3, ?4)", + params![workspace_id, worker.runtime_id, worker.worker_id, revision as i64], + )?; + tx.commit()?; + Ok(WorkerRegistryProjectionCommit { + revision, + changed_workers, + }) + }) + } + fn update_worker_retention( &self, workspace_id: &str, @@ -7496,15 +7904,15 @@ fn repository_registration_intent_matches( } fn validate_repository_record_identity(record: &RepositoryRecord) -> Result<()> { - workspace_api::validate_repository_key(&record.repository_key) + server_api::validate_repository_key(&record.repository_key) .map_err(|error| Error::InvalidInput(format!("invalid Repository key: {error}")))?; Ok(()) } fn read_repository_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { let source_kind_value = row.get::<_, String>(5)?; - let source_kind = workspace_api::RepositorySourceKind::parse(&source_kind_value) - .unwrap_or(workspace_api::RepositorySourceKind::Invalid); + let source_kind = server_api::RepositorySourceKind::parse(&source_kind_value) + .unwrap_or(server_api::RepositorySourceKind::Invalid); let source_revision = row.get::<_, u64>(8)?; let source_fingerprint = row.get::<_, String>(9)?; let mut source = RepositorySource { @@ -7517,7 +7925,7 @@ fn read_repository_record(row: &rusqlite::Row<'_>) -> rusqlite::Result) -> rusqlite::Result Result> { + let registry = conn + .query_row( + "SELECT workspace_id, runtime_id, worker_id, display_name, profile, retention_state, transcript_ref, session_ref, summary_ref, diagnostics_ref, created_at, updated_at FROM worker_registry WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", + params![workspace_id, worker.runtime_id, worker.worker_id], + read_worker_registry_record, + ) + .optional()?; + let Some(registry) = registry else { + return Ok(None); + }; + let resource_key = conn + .query_row( + "SELECT resource_key FROM workspace_resource_keys WHERE workspace_id = ?1 AND resource_kind = 'worker' AND resource_id = ?2", + params![workspace_id, worker.worker_id], + |row| row.get(0), + ) + .optional()?; + let observation = conn + .query_row( + "SELECT availability, worker_json, connection_generation, subject_revision, snapshot_revision, projection_revision, observed_at FROM worker_registry_observations WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", + params![workspace_id, worker.runtime_id, worker.worker_id], + |row| { + let availability: String = row.get(0)?; + let worker_json: Option = row.get(1)?; + Ok((availability, worker_json, row.get::<_, i64>(2)?, row.get::<_, i64>(3)?, row.get::<_, i64>(4)?, row.get::<_, i64>(5)?, row.get::<_, String>(6)?)) + }, + ) + .optional()?; + let observation = observation + .map( + |( + availability, + worker_json, + generation, + subject_revision, + snapshot_revision, + projection_revision, + observed_at, + )| + -> Result<_> { + let availability = match availability.as_str() { + "observed" => SubscriptionWorkerAvailability::Observed, + "unavailable" => SubscriptionWorkerAvailability::Unavailable, + other => { + return Err(Error::InvalidInput(format!( + "invalid Worker observation availability `{other}`" + ))); + } + }; + let mut observed_worker: SubscriptionWorker = if let Some(worker_json) = worker_json + { + serde_json::from_str(&worker_json).map_err(|error| { + Error::InvalidInput(format!("invalid stored Worker observation: {error}")) + })? + } else { + SubscriptionWorker { + worker_id: SubscriptionWorkerId::new(worker.worker_id.to_string()) + .map_err(|error| Error::InvalidInput(error.to_string()))?, + runtime_id: Some(worker.runtime_id.to_string()), + resource_key: resource_key.clone(), + availability, + subject_revision: subject_revision.max(0) as u64, + state: protocol::subscription::SubscriptionWorkerState::Stopped, + worker_state: None, + has_running_internal_workers: false, + workspace_id: Some(workspace_id.to_string()), + display_name: Some(registry.display_name.clone()), + profile: registry.profile.clone(), + repository_id: None, + repository_key: None, + working_directory_id: None, + } + }; + observed_worker.availability = availability; + observed_worker.resource_key = resource_key.clone(); + Ok(WorkerRegistryObservationRecord { + worker: observed_worker, + availability, + connection_generation: generation.max(0) as u64, + subject_revision: subject_revision.max(0) as u64, + snapshot_revision: snapshot_revision.max(0) as u64, + projection_revision: projection_revision.max(0) as u64, + observed_at, + }) + }, + ) + .transpose()?; + Ok(Some(WorkerRegistryProjectionRecord { + registry, + resource_key, + observation, + })) +} + +fn current_worker_projection_revision(conn: &Connection, workspace_id: &str) -> Result { + Ok(conn + .query_row( + "SELECT revision FROM worker_registry_projection_revisions WHERE workspace_id = ?1", + params![workspace_id], + |row| row.get::<_, i64>(0), + ) + .optional()? + .unwrap_or(0) + .max(0) as u64) +} + +fn worker_projection_cursor( + conn: &Connection, + workspace_id: &str, + runtime_id: &str, +) -> Result<(u64, u64)> { + Ok(conn.query_row( + "SELECT connection_generation, snapshot_revision FROM worker_registry_projection_cursors WHERE workspace_id = ?1 AND runtime_id = ?2", + params![workspace_id, runtime_id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + ).optional()?.map(|(generation, revision)| (generation.max(0) as u64, revision.max(0) as u64)).unwrap_or((0, 0))) +} + +fn upsert_worker_projection_cursor( + conn: &Connection, + workspace_id: &str, + runtime_id: &str, + generation: u64, + snapshot_revision: u64, +) -> Result<()> { + conn.execute( + "INSERT INTO worker_registry_projection_cursors (workspace_id, runtime_id, connection_generation, snapshot_revision) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(workspace_id, runtime_id) DO UPDATE SET connection_generation = excluded.connection_generation, snapshot_revision = excluded.snapshot_revision", + params![workspace_id, runtime_id, generation as i64, snapshot_revision as i64], + )?; + Ok(()) +} + +fn worker_registry_identities_for_runtime( + conn: &Connection, + workspace_id: &str, + runtime_id: &str, +) -> Result> { + let mut statement = conn.prepare("SELECT runtime_id, worker_id FROM worker_registry WHERE workspace_id = ?1 AND runtime_id = ?2 ORDER BY worker_id")?; + Ok(statement + .query_map(params![workspace_id, runtime_id], |row| { + Ok(RuntimeWorkerRef { + runtime_id: row.get(0)?, + worker_id: row.get(1)?, + }) + })? + .collect::, _>>()?) +} + +fn worker_registry_identity_exists( + conn: &Connection, + workspace_id: &str, + worker: &RuntimeWorkerRef, +) -> Result { + Ok(conn.query_row( + "SELECT 1 FROM worker_registry WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", + params![workspace_id, worker.runtime_id, worker.worker_id], + |_| Ok(()), + ).optional()?.is_some()) +} + +fn insert_worker_projection_orphan_diagnostic( + conn: &Connection, + workspace_id: &str, + runtime_id: &str, + worker_id: &str, + reason: &str, + observed_at: &str, +) -> Result<()> { + conn.execute( + "INSERT INTO worker_registry_projection_diagnostics (workspace_id, runtime_id, worker_id, reason, observed_at) VALUES (?1, ?2, ?3, ?4, ?5)", + params![workspace_id, runtime_id, worker_id, reason, observed_at], + )?; + Ok(()) +} + +fn upsert_worker_observation( + conn: &Connection, + workspace_id: &str, + runtime_id: &str, + connection_generation: u64, + snapshot_revision: u64, + worker: &SubscriptionWorker, + availability: SubscriptionWorkerAvailability, + observed_at: &str, +) -> Result { + let existing = conn.query_row( + "SELECT availability, worker_json, connection_generation, subject_revision FROM worker_registry_observations WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", + params![workspace_id, runtime_id, worker.worker_id.as_str()], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, Option>(1)?, row.get::<_, i64>(2)?, row.get::<_, i64>(3)?)), + ).optional()?; + if let Some((old_availability, _, generation, revision)) = existing.as_ref() { + let generation = (*generation).max(0) as u64; + let revision = (*revision).max(0) as u64; + let restores_unavailable = old_availability == "unavailable" + && availability == SubscriptionWorkerAvailability::Observed; + if connection_generation < generation + || (connection_generation == generation + && (worker.subject_revision < revision + || (worker.subject_revision == revision && !restores_unavailable))) + { + return Ok(false); + } + } + let mut persisted_worker = worker.clone(); + persisted_worker.runtime_id = Some(runtime_id.to_string()); + persisted_worker.availability = availability; + let worker_json = serde_json::to_string(&persisted_worker).map_err(|error| { + Error::InvalidInput(format!("failed to encode Worker observation: {error}")) + })?; + let availability_text = match availability { + SubscriptionWorkerAvailability::Observed => "observed", + SubscriptionWorkerAvailability::Unavailable => "unavailable", + }; + let changed = existing + .as_ref() + .is_none_or(|(old_availability, old_json, _, _)| { + old_availability != availability_text + || old_json.as_deref() != Some(worker_json.as_str()) + }); + conn.execute( + "INSERT INTO worker_registry_observations (workspace_id, runtime_id, worker_id, availability, worker_json, connection_generation, subject_revision, snapshot_revision, projection_revision, observed_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 0, ?9) ON CONFLICT(workspace_id, runtime_id, worker_id) DO UPDATE SET availability = excluded.availability, worker_json = excluded.worker_json, connection_generation = excluded.connection_generation, subject_revision = excluded.subject_revision, snapshot_revision = MAX(worker_registry_observations.snapshot_revision, excluded.snapshot_revision), observed_at = excluded.observed_at", + params![workspace_id, runtime_id, worker.worker_id.as_str(), availability_text, worker_json, connection_generation as i64, worker.subject_revision as i64, snapshot_revision as i64, observed_at], + )?; + Ok(changed) +} + +fn set_worker_observation_unavailable( + conn: &Connection, + workspace_id: &str, + worker: &RuntimeWorkerRef, + connection_generation: u64, + subject_revision: Option, + snapshot_revision: u64, + observed_at: &str, +) -> Result { + let existing = conn.query_row( + "SELECT availability, connection_generation, subject_revision, worker_json FROM worker_registry_observations WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", + params![workspace_id, worker.runtime_id, worker.worker_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?, row.get::<_, i64>(2)?, row.get::<_, Option>(3)?)), + ).optional()?; + if let Some((availability, generation, revision, _)) = existing.as_ref() { + let generation = (*generation).max(0) as u64; + let revision = (*revision).max(0) as u64; + if connection_generation < generation + || (connection_generation == generation + && subject_revision.is_some_and(|incoming| incoming <= revision)) + { + return Ok(false); + } + if availability == "unavailable" + && subject_revision.is_none() + && connection_generation == generation + { + return Ok(false); + } + } + let revision = subject_revision + .or_else(|| existing.as_ref().map(|item| item.2.max(0) as u64)) + .unwrap_or(0); + let worker_json = existing.and_then(|item| item.3); + conn.execute( + "INSERT INTO worker_registry_observations (workspace_id, runtime_id, worker_id, availability, worker_json, connection_generation, subject_revision, snapshot_revision, projection_revision, observed_at) VALUES (?1, ?2, ?3, 'unavailable', ?4, ?5, ?6, ?7, 0, ?8) ON CONFLICT(workspace_id, runtime_id, worker_id) DO UPDATE SET availability = 'unavailable', connection_generation = excluded.connection_generation, subject_revision = excluded.subject_revision, snapshot_revision = MAX(worker_registry_observations.snapshot_revision, excluded.snapshot_revision), observed_at = excluded.observed_at", + params![workspace_id, worker.runtime_id, worker.worker_id, worker_json, connection_generation as i64, revision as i64, snapshot_revision as i64, observed_at], + )?; + Ok(true) +} + +fn commit_worker_projection_changes( + conn: &Connection, + workspace_id: &str, + changed_workers: &[RuntimeWorkerRef], +) -> Result { + let current = current_worker_projection_revision(conn, workspace_id)?; + if changed_workers.is_empty() { + return Ok(current); + } + let revision = current.saturating_add(1); + conn.execute( + "INSERT INTO worker_registry_projection_revisions (workspace_id, revision) VALUES (?1, ?2) ON CONFLICT(workspace_id) DO UPDATE SET revision = excluded.revision", + params![workspace_id, revision as i64], + )?; + for worker in changed_workers { + conn.execute( + "UPDATE worker_registry_observations SET projection_revision = ?1 WHERE workspace_id = ?2 AND runtime_id = ?3 AND worker_id = ?4", + params![revision as i64, workspace_id, worker.runtime_id, worker.worker_id], + )?; + } + Ok(revision) +} + fn read_worker_control_grant_record( row: &rusqlite::Row<'_>, ) -> rusqlite::Result { @@ -9784,11 +10487,22 @@ fn migrate_runtime_removal_operations_v59_to_v60(conn: &Connection) -> Result<() fn migrate_worker_run_generation_v60_to_v61(conn: &Connection) -> Result<()> { let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; tx.execute_batch("ALTER TABLE worker_removal_operations DROP COLUMN run_generation;")?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![61, REMOVE_WORKER_RUN_GENERATION_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + +fn migrate_worker_registry_projection_v61_to_v62(conn: &Connection) -> Result<()> { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch(WORKER_REGISTRY_PROJECTION_SCHEMA)?; tx.execute( "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", params![ LATEST_SCHEMA_VERSION, - REMOVE_WORKER_RUN_GENERATION_MIGRATION_NAME + WORKER_REGISTRY_PROJECTION_MIGRATION_NAME ], )?; tx.commit()?; @@ -11166,6 +11880,10 @@ mod tests { version: 61, name: REMOVE_WORKER_RUN_GENERATION_MIGRATION_NAME.to_string(), }, + WorkspaceSchemaMigrationStep { + version: 62, + name: WORKER_REGISTRY_PROJECTION_MIGRATION_NAME.to_string(), + }, ] ); @@ -11216,6 +11934,7 @@ mod tests { 61, REMOVE_WORKER_RUN_GENERATION_MIGRATION_NAME.to_string(), ), + (62, WORKER_REGISTRY_PROJECTION_MIGRATION_NAME.to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -11286,7 +12005,7 @@ mod tests { .iter() .map(|migration| migration.version) .collect::>(), - vec![52, 53, 54, 55, 56, 57, 58, 59, 60, 61] + vec![52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62] ); SqliteWorkspaceStore::migrate_database(&path).unwrap(); let conn = Connection::open(&path).unwrap(); @@ -11294,7 +12013,7 @@ mod tests { current_schema_version(&conn).unwrap(), LATEST_SCHEMA_VERSION ); - assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 12); + assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 13); } #[test] @@ -12411,7 +13130,7 @@ mod tests { kind: "git".to_string(), provider: Some("git".to_string()), source: RepositorySource { - kind: workspace_api::RepositorySourceKind::LocalPath, + kind: server_api::RepositorySourceKind::LocalPath, uri: "/repo-a".to_string(), }, default_ref: Some("HEAD".to_string()), @@ -13255,7 +13974,7 @@ INSERT INTO worker_registry ( kind: "git".to_string(), provider: Some("git".to_string()), source: RepositorySource { - kind: workspace_api::RepositorySourceKind::File, + kind: server_api::RepositorySourceKind::File, uri: "file:///tmp/main".to_string(), }, default_ref: Some("develop".to_string()), @@ -13672,8 +14391,8 @@ INSERT INTO worker_registry ( repository_key: "Invalid_Key".to_string(), kind: "git".to_string(), provider: Some("git".to_string()), - source: workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::LocalPath, + source: server_api::RepositorySource { + kind: server_api::RepositorySourceKind::LocalPath, uri: "/repo".to_string(), }, default_ref: Some("develop".to_string()), @@ -13723,8 +14442,8 @@ INSERT INTO worker_registry ( repository_key: "main".to_string(), kind: "git".to_string(), provider: Some("git".to_string()), - source: workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::LocalPath, + source: server_api::RepositorySource { + kind: server_api::RepositorySourceKind::LocalPath, uri: "/repo".to_string(), }, default_ref: Some("develop".to_string()), @@ -13798,14 +14517,14 @@ INSERT INTO worker_registry ( kind: "git".to_string(), provider: Some("git".to_string()), source: RepositorySource { - kind: workspace_api::RepositorySourceKind::LocalPath, + kind: server_api::RepositorySourceKind::LocalPath, uri: "/repo".to_string(), }, default_ref: Some("HEAD".to_string()), source_revision: 1, source_fingerprint: crate::repository_source::repository_source_fingerprint( &RepositorySource { - kind: workspace_api::RepositorySourceKind::LocalPath, + kind: server_api::RepositorySourceKind::LocalPath, uri: "/repo".to_string(), }, ), @@ -13955,7 +14674,7 @@ INSERT INTO worker_registry ( kind: "git".to_string(), provider: Some("git".to_string()), source: RepositorySource { - kind: workspace_api::RepositorySourceKind::LocalPath, + kind: server_api::RepositorySourceKind::LocalPath, uri: "/repo".to_string(), }, default_ref: Some("HEAD".to_string()), @@ -14143,6 +14862,223 @@ INSERT INTO worker_registry ( ); } + #[tokio::test] + async fn worker_projection_reconciles_snapshots_and_preserves_unavailable_lifecycle() { + let temp = tempfile::tempdir().unwrap(); + let store = SqliteWorkspaceStore::open(temp.path().join("workspace.db")).unwrap(); + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: "local-dev".to_string(), + owner_account_id: "owner-account".to_string(), + display_name: "Local Dev".to_string(), + state: "active".to_string(), + created_at: "1".to_string(), + updated_at: "1".to_string(), + }) + .await + .unwrap(); + let catalog = WorkerRegistryRecord { + workspace_id: "local-dev".to_string(), + worker: RuntimeWorkerRef::new("embedded", "known"), + display_name: "Known Worker".to_string(), + profile: None, + retention_state: "normal".to_string(), + transcript_ref: None, + session_ref: None, + summary_ref: None, + diagnostics_ref: None, + created_at: "1".to_string(), + updated_at: "1".to_string(), + }; + store.upsert_worker_registry(&catalog).unwrap(); + assert_eq!( + store + .resource_key("local-dev", WorkspaceResourceKind::Worker, "known") + .unwrap() + .as_deref(), + Some("W-1") + ); + assert_eq!( + store + .worker_registry_projection("local-dev", &catalog.worker) + .unwrap() + .unwrap() + .resource_key + .as_deref(), + Some("W-1") + ); + let observed = SubscriptionWorker { + worker_id: SubscriptionWorkerId::new("known").unwrap(), + runtime_id: Some("embedded".to_string()), + resource_key: None, + availability: SubscriptionWorkerAvailability::Observed, + subject_revision: 4, + worker_state: None, + state: protocol::subscription::SubscriptionWorkerState::Running, + has_running_internal_workers: true, + workspace_id: Some("local-dev".to_string()), + display_name: Some("Known Worker".to_string()), + profile: None, + repository_id: None, + repository_key: None, + working_directory_id: None, + }; + let orphan = SubscriptionWorker { + worker_id: SubscriptionWorkerId::new("orphan").unwrap(), + ..observed.clone() + }; + let first = store + .reconcile_worker_registry_snapshot( + "local-dev", + "embedded", + 1, + 10, + &[observed.clone(), orphan], + "2", + ) + .unwrap(); + assert_eq!(first.changed_workers, vec![catalog.worker.clone()]); + let (_, records) = store + .worker_registry_projection_snapshot("local-dev", 10) + .unwrap(); + assert_eq!( + records.len(), + 1, + "orphan Runtime rows must not create catalog rows" + ); + let observation = records[0].observation.as_ref().unwrap(); + assert_eq!( + observation.availability, + SubscriptionWorkerAvailability::Observed + ); + assert_eq!( + observation.worker.state, + protocol::subscription::SubscriptionWorkerState::Running + ); + + let unavailable = store + .reconcile_worker_registry_snapshot("local-dev", "embedded", 1, 11, &[], "3") + .unwrap(); + assert_eq!(unavailable.changed_workers, vec![catalog.worker.clone()]); + let projection = store + .worker_registry_projection("local-dev", &catalog.worker) + .unwrap() + .unwrap(); + let observation = projection.observation.unwrap(); + assert_eq!( + observation.availability, + SubscriptionWorkerAvailability::Unavailable + ); + assert_eq!( + observation.worker.state, + protocol::subscription::SubscriptionWorkerState::Running, + "unavailable must retain the last lifecycle observation rather than becoming stopped" + ); + + let duplicate = store + .reconcile_worker_registry_snapshot("local-dev", "embedded", 1, 11, &[], "4") + .unwrap(); + assert!(duplicate.changed_workers.is_empty()); + + let reconnected = store + .reconcile_worker_registry_snapshot( + "local-dev", + "embedded", + 1, + 11, + &[observed.clone()], + "5", + ) + .unwrap(); + assert_eq!(reconnected.changed_workers, vec![catalog.worker.clone()]); + let projection = store + .worker_registry_projection("local-dev", &catalog.worker) + .unwrap() + .unwrap(); + assert_eq!( + projection.observation.unwrap().availability, + SubscriptionWorkerAvailability::Observed, + "an equal-revision reconnect snapshot must restore availability" + ); + let duplicate_reconnect = store + .reconcile_worker_registry_snapshot("local-dev", "embedded", 1, 11, &[observed], "6") + .unwrap(); + assert!(duplicate_reconnect.changed_workers.is_empty()); + } + + #[tokio::test] + async fn late_runtime_event_does_not_recreate_retention_deleted_catalog_worker() { + let temp = tempfile::tempdir().unwrap(); + let store = SqliteWorkspaceStore::open(temp.path().join("workspace.db")).unwrap(); + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: "local-dev".to_string(), + owner_account_id: "owner-account".to_string(), + display_name: "Local Dev".to_string(), + state: "active".to_string(), + created_at: "1".to_string(), + updated_at: "1".to_string(), + }) + .await + .unwrap(); + let worker_ref = RuntimeWorkerRef::new("embedded", "removed"); + store + .upsert_worker_registry(&WorkerRegistryRecord { + workspace_id: "local-dev".to_string(), + worker: worker_ref.clone(), + display_name: "Removed Worker".to_string(), + profile: None, + retention_state: "normal".to_string(), + transcript_ref: None, + session_ref: None, + summary_ref: None, + diagnostics_ref: None, + created_at: "1".to_string(), + updated_at: "1".to_string(), + }) + .unwrap(); + assert!( + store + .delete_worker_registry("local-dev", &worker_ref) + .unwrap() + ); + let first_removal = store + .publish_worker_registry_removal("local-dev", &worker_ref) + .unwrap(); + assert_eq!(first_removal.changed_workers, vec![worker_ref.clone()]); + let duplicate_removal = store + .publish_worker_registry_removal("local-dev", &worker_ref) + .unwrap(); + assert!(duplicate_removal.changed_workers.is_empty()); + assert_eq!(duplicate_removal.revision, first_removal.revision); + let event = SubscriptionWorker { + worker_id: SubscriptionWorkerId::new("removed").unwrap(), + runtime_id: Some("embedded".to_string()), + resource_key: None, + availability: SubscriptionWorkerAvailability::Observed, + subject_revision: 99, + worker_state: None, + state: protocol::subscription::SubscriptionWorkerState::Running, + has_running_internal_workers: false, + workspace_id: Some("local-dev".to_string()), + display_name: None, + profile: None, + repository_id: None, + repository_key: None, + working_directory_id: None, + }; + let commit = store + .apply_worker_registry_observation("local-dev", "embedded", 2, &event, "2") + .unwrap(); + assert!(commit.changed_workers.is_empty()); + assert!( + store + .worker_registry_projection("local-dev", &worker_ref) + .unwrap() + .is_none() + ); + } + #[tokio::test] async fn worker_control_grants_are_idempotent_scoped_and_support_internal_invalidation() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/workspace-server/src/workdir_create_operations.rs b/crates/workspace-server/src/workdir_create_operations.rs index 22a71cbe2..46daeee99 100644 --- a/crates/workspace-server/src/workdir_create_operations.rs +++ b/crates/workspace-server/src/workdir_create_operations.rs @@ -549,14 +549,14 @@ mod tests { repository_key: "main".to_string(), kind: "git".to_string(), provider: Some("git".to_string()), - source: workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::LocalPath, + source: server_api::RepositorySource { + kind: server_api::RepositorySourceKind::LocalPath, uri: "/tmp/main".to_string(), }, default_ref: Some("develop".to_string()), source_revision: 1, source_fingerprint: "sha256:test".to_string(), - observed_status: workspace_api::RepositoryObservedStatus::Unverified, + observed_status: server_api::RepositoryObservedStatus::Unverified, observed_at: None, created_at: "2026-08-24T00:00:00Z".to_string(), updated_at: "2026-08-24T00:00:00Z".to_string(), diff --git a/crates/workspace-server/src/workdir_removal.rs b/crates/workspace-server/src/workdir_removal.rs index 87c2e9b14..0cb2d4d78 100644 --- a/crates/workspace-server/src/workdir_removal.rs +++ b/crates/workspace-server/src/workdir_removal.rs @@ -879,7 +879,7 @@ fn hex_sha256(bytes: &[u8]) -> String { mod tests { use super::*; use crate::store::{AccountRecord, ControlPlaneStore, RepositoryRecord, WorkspaceRecord}; - use workspace_api::{RepositoryObservedStatus, RepositorySource, RepositorySourceKind}; + use server_api::{RepositoryObservedStatus, RepositorySource, RepositorySourceKind}; fn attempt_owner() -> WorkdirRemovalAttemptOwner { WorkdirRemovalAttemptOwner { diff --git a/crates/workspace-server/src/worker_projection.rs b/crates/workspace-server/src/worker_projection.rs new file mode 100644 index 000000000..36b01a001 --- /dev/null +++ b/crates/workspace-server/src/worker_projection.rs @@ -0,0 +1,279 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use chrono::Utc; +use protocol::subscription::{ + EventSubscriptionSelector, SubscriptionEventPayload, SubscriptionSnapshot, +}; +use tokio::sync::broadcast; +use tokio::task::JoinHandle; + +use crate::runtime_subscription::{ + BrokerSubscriptionEvent, RuntimeSubscriptionBroker, RuntimeSubscriptionBrokerError, +}; +use crate::store::{ + ControlPlaneStore, WorkerRegistryProjectionCommit, WorkerRegistryProjectionRecord, +}; +use worker_runtime::identity::RuntimeWorkerRef; + +const PROJECTION_EVENT_CAPACITY: usize = 256; + +#[derive(Debug, Clone)] +pub enum WorkerProjectionChange { + Upsert(WorkerRegistryProjectionRecord), + Removed(RuntimeWorkerRef), +} + +#[derive(Debug, Clone)] +pub struct WorkerProjectionEvent { + pub revision: u64, + pub changes: Vec, +} + +/// Owns the durable Runtime-to-Workspace Worker projection and its independent +/// low-frequency publication stream. +/// +/// One Runtime subscription is kept alive per registered Runtime even when no +/// browser is connected. Browser subscriptions therefore never become the +/// authority for populating `worker_registry` observations. +pub struct WorkerProjectionService { + workspace_id: String, + store: Arc, + events: broadcast::Sender, + connection_generation_epoch: u64, + tasks: Mutex>>, +} + +impl WorkerProjectionService { + pub fn new(workspace_id: impl Into, store: Arc) -> Arc { + let (events, _) = broadcast::channel(PROJECTION_EVENT_CAPACITY); + Arc::new(Self { + workspace_id: workspace_id.into(), + store, + events, + connection_generation_epoch: Utc::now().timestamp_nanos_opt().unwrap_or_default().max(0) + as u64, + tasks: Mutex::new(HashMap::new()), + }) + } + + pub fn shutdown(&self) { + for (_, task) in self + .tasks + .lock() + .expect("Worker projection task lock poisoned") + .drain() + { + task.abort(); + } + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.events.subscribe() + } + + pub async fn attach_runtime( + self: &Arc, + broker: &RuntimeSubscriptionBroker, + runtime_id: &str, + ) -> Result<(), RuntimeSubscriptionBrokerError> { + let mut subscription = + broker.subscribe(runtime_id, EventSubscriptionSelector::RuntimeWorkers)?; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + let Ok(Some(event)) = tokio::time::timeout_at(deadline, subscription.recv()).await + else { + break; + }; + let initialized = matches!(event, BrokerSubscriptionEvent::Snapshot { .. }); + if let Err(error) = self.apply_broker_event(runtime_id, event).await { + tracing::warn!( + runtime_id, + error = %error, + "failed to initialize durable Worker projection" + ); + } + if initialized { + break; + } + } + let service = Arc::downgrade(self); + let runtime_id = runtime_id.to_string(); + let task_runtime_id = runtime_id.clone(); + let task = tokio::spawn(async move { + while let Some(event) = subscription.recv().await { + let Some(service) = service.upgrade() else { + return; + }; + if let Err(error) = service + .apply_broker_event(task_runtime_id.as_str(), event) + .await + { + tracing::warn!( + runtime_id = %task_runtime_id, + error = %error, + "failed to update durable Worker projection" + ); + } + } + }); + if let Some(previous) = self + .tasks + .lock() + .expect("Worker projection task lock poisoned") + .insert(runtime_id, task) + { + previous.abort(); + } + Ok(()) + } + + pub fn seed_observation( + &self, + runtime_id: &str, + worker: &protocol::subscription::SubscriptionWorker, + observed_at: &str, + ) -> crate::Result<()> { + let commit = self.store.apply_worker_registry_observation( + self.workspace_id.as_str(), + runtime_id, + 0, + worker, + observed_at, + )?; + self.publish_commit(commit) + } + + pub fn publish_catalog_change(&self, worker: &RuntimeWorkerRef) -> crate::Result<()> { + let commit = self + .store + .publish_worker_registry_catalog_change(self.workspace_id.as_str(), worker)?; + self.publish_commit(commit) + } + + pub fn publish_removed(&self, worker: RuntimeWorkerRef) -> crate::Result<()> { + let commit = self + .store + .publish_worker_registry_removal(self.workspace_id.as_str(), &worker)?; + if commit.changed_workers.is_empty() { + return Ok(()); + } + let _ = self.events.send(WorkerProjectionEvent { + revision: commit.revision, + changes: vec![WorkerProjectionChange::Removed(worker)], + }); + Ok(()) + } + + async fn apply_broker_event( + &self, + runtime_id: &str, + event: BrokerSubscriptionEvent, + ) -> crate::Result<()> { + let observed_at = Utc::now().to_rfc3339(); + let projection_generation = |connection_generation: u64| { + self.connection_generation_epoch + .saturating_add(connection_generation) + }; + let commit = match event { + BrokerSubscriptionEvent::Snapshot { + connection_generation, + snapshot_revision, + snapshot: SubscriptionSnapshot::Workers { workers }, + } => self.store.reconcile_worker_registry_snapshot( + self.workspace_id.as_str(), + runtime_id, + projection_generation(connection_generation), + snapshot_revision, + workers.as_slice(), + observed_at.as_str(), + )?, + BrokerSubscriptionEvent::Snapshot { .. } => return Ok(()), + BrokerSubscriptionEvent::Event { + connection_generation, + payload: SubscriptionEventPayload::WorkerUpserted { worker }, + .. + } => self.store.apply_worker_registry_observation( + self.workspace_id.as_str(), + runtime_id, + projection_generation(connection_generation), + &worker, + observed_at.as_str(), + )?, + BrokerSubscriptionEvent::Event { + connection_generation, + subject_revision, + payload: SubscriptionEventPayload::WorkerRemoved { worker_id, .. }, + } => self.store.mark_worker_registry_observation_unavailable( + self.workspace_id.as_str(), + runtime_id, + projection_generation(connection_generation), + Some(worker_id.as_str()), + Some(subject_revision), + observed_at.as_str(), + )?, + BrokerSubscriptionEvent::Event { .. } => return Ok(()), + BrokerSubscriptionEvent::Disconnected { + connection_generation, + .. + } + | BrokerSubscriptionEvent::Rejected { + connection_generation, + .. + } + | BrokerSubscriptionEvent::Closed { + connection_generation, + .. + } => self.store.mark_worker_registry_observation_unavailable( + self.workspace_id.as_str(), + runtime_id, + projection_generation(connection_generation), + None, + None, + observed_at.as_str(), + )?, + }; + self.publish_commit(commit) + } + + fn publish_commit(&self, commit: WorkerRegistryProjectionCommit) -> crate::Result<()> { + if commit.changed_workers.is_empty() { + return Ok(()); + } + let changes = commit + .changed_workers + .iter() + .filter_map(|worker| { + match self + .store + .worker_registry_projection(self.workspace_id.as_str(), worker) + { + Ok(Some(record)) => Some(WorkerProjectionChange::Upsert(record)), + Ok(None) => None, + Err(error) => { + tracing::warn!( + runtime_id = %worker.runtime_id, + worker_id = %worker.worker_id, + error = %error, + "failed to load committed Worker projection" + ); + None + } + } + }) + .collect::>(); + if !changes.is_empty() { + let _ = self.events.send(WorkerProjectionEvent { + revision: commit.revision, + changes, + }); + } + Ok(()) + } +} + +impl Drop for WorkerProjectionService { + fn drop(&mut self) { + self.shutdown(); + } +} diff --git a/crates/workspace-server/src/workspace_catalog.rs b/crates/workspace-server/src/workspace_catalog.rs index b62a8b42e..3dee38110 100644 --- a/crates/workspace-server/src/workspace_catalog.rs +++ b/crates/workspace-server/src/workspace_catalog.rs @@ -4,8 +4,8 @@ use chrono::{SecondsFormat, Utc}; use sha2::{Digest, Sha256}; use uuid::Uuid; -pub use workspace_api::{InitialRepositoryIntent, WorkspaceCreateRequest}; -use workspace_api::{RepositoryObservedStatus, RepositorySource}; +pub use server_api::{InitialRepositoryIntent, WorkspaceCreateRequest}; +use server_api::{RepositoryObservedStatus, RepositorySource}; use crate::repository_source::{parse_repository_source, repository_source_fingerprint}; use crate::store::{ @@ -96,7 +96,7 @@ impl WorkspaceCatalogService { normalize_required("display_name", request.display_name, MAX_DISPLAY_NAME_BYTES)?; let repository_source = validate_repository_source(&request.repository.uri)?; let repository_uri = repository_source.uri.clone(); - workspace_api::validate_repository_key(&request.repository.repository_key) + server_api::validate_repository_key(&request.repository.repository_key) .map_err(|error| Error::InvalidInput(format!("invalid Repository key: {error}")))?; let repository_key = request.repository.repository_key.clone(); let default_ref = request @@ -228,7 +228,7 @@ mod tests { InMemoryWorkspaceSigningMaterialStore, WorkspaceSigningMaterialStore, WorkspaceSigningPrivateMaterial, identity_error, }; - use workspace_api::RepositorySourceKind; + use server_api::RepositorySourceKind; struct FailFirstMaterialWrite { inner: Arc, diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index a74f2c518..3dbfa46f7 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -1,7 +1,6 @@ use chrono::Utc; use rusqlite::{OptionalExtension, params}; -use sha2::{Digest, Sha256}; -use workspace_api::{ +use server_api::{ WORKSPACE_DELETION_MAX_BLOCKER_MESSAGE_BYTES, WORKSPACE_DELETION_MAX_BLOCKERS, WORKSPACE_DELETION_MAX_CHILD_OPERATION_IDS, WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES, WORKSPACE_DELETION_MAX_RESOURCE_VALUE_BYTES, WORKSPACE_DELETION_MAX_REVISION_BYTES, @@ -9,6 +8,7 @@ use workspace_api::{ WorkspaceDeletionPreflightResponse, WorkspaceDeletionRequest, WorkspaceDeletionResourceCounts, WorkspaceDeletionState, }; +use sha2::{Digest, Sha256}; use crate::store::{SqliteWorkspaceStore, WorkspaceRecord}; use crate::{Error, Result}; @@ -67,6 +67,11 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "worker_mutation_source_proof_jtis", "worker_orphan_diagnostics", "worker_registry", + "worker_registry_observations", + "worker_registry_projection_cursors", + "worker_registry_projection_diagnostics", + "worker_registry_projection_removals", + "worker_registry_projection_revisions", "worker_removal_operations", "worker_retention_audit_events", "worker_session_archives", @@ -200,7 +205,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { ) -> Result { validate_operation_id(&request.operation_id)?; if request.expected_revision.len() > WORKSPACE_DELETION_MAX_REVISION_BYTES - || request.confirmation.len() > workspace_api::WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES + || request.confirmation.len() > server_api::WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES { return Err(Error::InvalidInput( "Workspace deletion request exceeds bounded field limits".to_string(), diff --git a/crates/workspace-server/src/workspace_subscription.rs b/crates/workspace-server/src/workspace_subscription.rs index 018a96221..f84c79df6 100644 --- a/crates/workspace-server/src/workspace_subscription.rs +++ b/crates/workspace-server/src/workspace_subscription.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::HashMap; use axum::extract::ws::{Message as WsMessage, WebSocket}; use futures::{SinkExt, StreamExt}; @@ -10,11 +10,10 @@ use protocol::subscription::{ use tokio::sync::mpsc; use worker_runtime::identity::RuntimeWorkerRef; -use crate::runtime_subscription::{BrokerSubscriptionEvent, RuntimeSubscriptionBroker}; +use crate::runtime_subscription::RuntimeSubscriptionBroker; use crate::server::{ WorkspaceApi, authorize_browser_worker_method, connect_workspace_worker_protocol, }; -use crate::store::WorkspaceResourceKind; const OUTBOUND_CAPACITY: usize = 256; @@ -293,62 +292,31 @@ async fn run_worker_protocol( async fn run_workspace_workers( api: WorkspaceApi, - broker: RuntimeSubscriptionBroker, + _broker: RuntimeSubscriptionBroker, request_id: protocol::subscription::SubscriptionRequestId, subscription_id: SubscriptionId, outbound: mpsc::Sender, ) { - let runtime_ids = broker.runtime_ids(); - let mut pending = runtime_ids.iter().cloned().collect::>(); - let (events, mut event_receiver) = mpsc::channel(OUTBOUND_CAPACITY); - let mut upstreams = tokio::task::JoinSet::new(); - for runtime_id in runtime_ids { - let Ok(mut subscription) = - broker.subscribe(&runtime_id, EventSubscriptionSelector::RuntimeWorkers) - else { - pending.remove(&runtime_id); - continue; - }; - let sender = events.clone(); - upstreams.spawn(async move { - while let Some(event) = subscription.recv().await { - if sender.send((runtime_id.clone(), event)).await.is_err() { - break; - } - } - }); - } - drop(events); - - let mut workers = HashMap::>::new(); - while !pending.is_empty() { - let Some((runtime_id, event)) = event_receiver.recv().await else { - return; - }; - match event { - BrokerSubscriptionEvent::Snapshot { snapshot, .. } => { - install_snapshot(&api, &mut workers, &runtime_id, snapshot); - pending.remove(&runtime_id); - } - BrokerSubscriptionEvent::Disconnected { .. } - | BrokerSubscriptionEvent::Rejected { .. } - | BrokerSubscriptionEvent::Closed { .. } => { - pending.remove(&runtime_id); - } - BrokerSubscriptionEvent::Event { .. } => {} - } - } - - let mut revisions = HashMap::::new(); - let mut initial_workers = Vec::new(); - for (runtime_id, runtime) in &mut workers { - for worker in runtime.values_mut() { - let worker_ref = RuntimeWorkerRef::new(runtime_id, worker.worker_id.as_str()); - worker.subject_revision = next_revision(&mut revisions, &worker_ref); - initial_workers.push(worker.clone()); - } - } - sort_workers(&mut initial_workers); + // Subscribe before reading so commits racing with the snapshot are replayed. + let mut events = api.worker_projection.subscribe(); + let Ok((snapshot_revision, records)) = api.store.worker_registry_projection_snapshot( + &api.config.workspace_id, + api.config.max_records.max(1), + ) else { + let _ = send_rejected( + &outbound, + request_id, + SubscriptionRejectionCode::Internal, + "failed to read durable Worker projection".to_string(), + ) + .await; + return; + }; + let mut workers = records + .into_iter() + .filter_map(|record| project_registry_worker(&api, record)) + .collect::>(); + sort_workers(&mut workers); if send_frame( &outbound, SubscriptionFrame::new(SubscriptionFramePayload::Response( @@ -356,10 +324,8 @@ async fn run_workspace_workers( request_id, subscription_id: subscription_id.clone(), selector: EventSubscriptionSelector::WorkspaceWorkers, - snapshot_revision: 1, - snapshot: SubscriptionSnapshot::Workers { - workers: initial_workers, - }, + snapshot_revision, + snapshot: SubscriptionSnapshot::Workers { workers }, }, )), ) @@ -369,174 +335,103 @@ async fn run_workspace_workers( return; } - while let Some((runtime_id, event)) = event_receiver.recv().await { - match event { - BrokerSubscriptionEvent::Snapshot { snapshot, .. } => { - let removed = workers.remove(&runtime_id).unwrap_or_default(); - for worker in removed.values() { - let worker_ref = RuntimeWorkerRef::new(&runtime_id, worker.worker_id.as_str()); - let revision = next_revision(&mut revisions, &worker_ref); - if send_event( - &outbound, - &subscription_id, - revision, - SubscriptionEventPayload::WorkerRemoved { - worker_id: worker.worker_id.clone(), - runtime_id: Some(runtime_id.clone()), - }, - ) - .await - .is_err() - { - return; - } - } - install_snapshot(&api, &mut workers, &runtime_id, snapshot); - if let Some(current) = workers.get_mut(&runtime_id) { - for worker in current.values_mut() { - let worker_ref = - RuntimeWorkerRef::new(&runtime_id, worker.worker_id.as_str()); - let revision = next_revision(&mut revisions, &worker_ref); - worker.subject_revision = revision; - if send_event( - &outbound, - &subscription_id, - revision, - SubscriptionEventPayload::WorkerUpserted { - worker: worker.clone(), - }, - ) + loop { + match events.recv().await { + Ok(event) if event.revision <= snapshot_revision => continue, + Ok(event) => { + for change in event.changes { + let payload = match change { + crate::worker_projection::WorkerProjectionChange::Upsert(record) => { + let Some(mut worker) = project_registry_worker(&api, record) else { + continue; + }; + worker.subject_revision = event.revision; + SubscriptionEventPayload::WorkerUpserted { worker } + } + crate::worker_projection::WorkerProjectionChange::Removed(worker) => { + let Ok(worker_id) = + protocol::subscription::SubscriptionWorkerId::new(worker.worker_id) + else { + continue; + }; + SubscriptionEventPayload::WorkerRemoved { + worker_id, + runtime_id: Some(worker.runtime_id), + } + } + }; + if send_event(&outbound, &subscription_id, event.revision, payload) .await .is_err() - { - return; - } + { + return; } } } - BrokerSubscriptionEvent::Event { payload, .. } => match payload { - SubscriptionEventPayload::WorkerUpserted { mut worker } => { - worker.runtime_id = Some(runtime_id.clone()); - let Ok(Some(resource_key)) = api.store.resource_key( - &api.config.workspace_id, - WorkspaceResourceKind::Worker, - worker.worker_id.as_str(), - ) else { - continue; - }; - worker.resource_key = Some(resource_key); - if !project_repository_key(&api, &mut worker) { - continue; - } - let worker_ref = RuntimeWorkerRef::new(&runtime_id, worker.worker_id.as_str()); - let revision = next_revision(&mut revisions, &worker_ref); - worker.subject_revision = revision; - workers - .entry(runtime_id) - .or_default() - .insert(worker.worker_id.to_string(), worker.clone()); - if send_event( - &outbound, - &subscription_id, - revision, - SubscriptionEventPayload::WorkerUpserted { worker }, - ) - .await - .is_err() - { - return; - } - } - SubscriptionEventPayload::WorkerRemoved { worker_id, .. } => { - workers - .entry(runtime_id.clone()) - .or_default() - .remove(worker_id.as_str()); - let worker_ref = RuntimeWorkerRef::new(&runtime_id, worker_id.as_str()); - let revision = next_revision(&mut revisions, &worker_ref); - if send_event( - &outbound, - &subscription_id, - revision, - SubscriptionEventPayload::WorkerRemoved { - worker_id, - runtime_id: Some(runtime_id), - }, - ) - .await - .is_err() - { - return; - } - } - _ => {} - }, - BrokerSubscriptionEvent::Disconnected { .. } => {} - BrokerSubscriptionEvent::Rejected { code, message, .. } => { + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { let _ = send_frame( &outbound, SubscriptionFrame::new(SubscriptionFramePayload::Event( SubscriptionEvent::SubscriptionClosed { subscription_id: subscription_id.clone(), - code: rejection_termination(code), - message, - }, - )), - ) - .await; - return; - } - BrokerSubscriptionEvent::Closed { code, message, .. } => { - let _ = send_frame( - &outbound, - SubscriptionFrame::new(SubscriptionFramePayload::Event( - SubscriptionEvent::SubscriptionClosed { - subscription_id: subscription_id.clone(), - code, - message, + code: protocol::subscription::SubscriptionTerminationCode::Lagged, + message: "durable Worker projection subscriber lagged; reconnect for a fresh snapshot".to_string(), }, )), ) .await; return; } + Err(tokio::sync::broadcast::error::RecvError::Closed) => return, } } } -fn install_snapshot( +fn project_registry_worker( api: &WorkspaceApi, - workers: &mut HashMap>, - runtime_id: &str, - snapshot: SubscriptionSnapshot, -) { - let SubscriptionSnapshot::Workers { - workers: snapshot_workers, - } = snapshot - else { - return; - }; - let mut projected = BTreeMap::new(); - for mut worker in snapshot_workers { - worker.runtime_id = Some(runtime_id.to_string()); - let Ok(Some(resource_key)) = api.store.resource_key( - &api.config.workspace_id, - WorkspaceResourceKind::Worker, - worker.worker_id.as_str(), - ) else { - continue; - }; - worker.resource_key = Some(resource_key); - if !project_repository_key(api, &mut worker) { - continue; + record: crate::store::WorkerRegistryProjectionRecord, +) -> Option { + let runtime_id = record.registry.worker.runtime_id.clone(); + let mut worker = if let Some(observation) = record.observation { + let mut worker = observation.worker; + worker.availability = observation.availability; + worker.subject_revision = observation.projection_revision; + worker + } else { + SubscriptionWorker { + worker_id: protocol::subscription::SubscriptionWorkerId::new( + record.registry.worker.worker_id.clone(), + ) + .ok()?, + runtime_id: Some(runtime_id.clone()), + resource_key: record.resource_key.clone(), + availability: protocol::subscription::SubscriptionWorkerAvailability::Unavailable, + subject_revision: 0, + worker_state: None, + state: protocol::subscription::SubscriptionWorkerState::Stopped, + has_running_internal_workers: false, + workspace_id: Some(record.registry.workspace_id.clone()), + display_name: Some(record.registry.display_name.clone()), + profile: record.registry.profile.clone(), + repository_id: None, + repository_key: None, + working_directory_id: None, } - projected.insert(worker.worker_id.to_string(), worker); + }; + worker.runtime_id = Some(runtime_id); + worker.resource_key = record.resource_key; + worker.workspace_id = Some(record.registry.workspace_id); + worker.display_name = Some(record.registry.display_name); + worker.profile = record.registry.profile; + if !project_repository_key(api, &mut worker) { + return None; } - workers.insert(runtime_id.to_string(), projected); + Some(worker) } fn project_repository_key(api: &WorkspaceApi, worker: &mut SubscriptionWorker) -> bool { let Some(repository_id) = worker.repository_id.take() else { + worker.repository_key = None; return true; }; let Ok(Some(repository)) = api @@ -549,6 +444,14 @@ fn project_repository_key(api: &WorkspaceApi, worker: &mut SubscriptionWorker) - true } +fn sort_workers(workers: &mut [SubscriptionWorker]) { + workers.sort_by(|left, right| { + left.runtime_id + .cmp(&right.runtime_id) + .then_with(|| left.worker_id.cmp(&right.worker_id)) + }); +} + async fn send_event( outbound: &mpsc::Sender, subscription_id: &SubscriptionId, @@ -586,23 +489,3 @@ async fn send_frame( .await .map_err(|_| ()) } - -fn next_revision(revisions: &mut HashMap, worker: &RuntimeWorkerRef) -> u64 { - let revision = revisions.entry(worker.clone()).or_insert(0); - *revision = revision.saturating_add(1); - *revision -} -fn sort_workers(workers: &mut [SubscriptionWorker]) { - workers.sort_by(|left, right| { - left.runtime_id - .cmp(&right.runtime_id) - .then_with(|| left.worker_id.cmp(&right.worker_id)) - }); -} -fn rejection_termination(code: SubscriptionRejectionCode) -> SubscriptionTerminationCode { - match code { - SubscriptionRejectionCode::Unauthorized => SubscriptionTerminationCode::Unauthorized, - SubscriptionRejectionCode::ResourceNotFound => SubscriptionTerminationCode::ResourceGone, - _ => SubscriptionTerminationCode::ServerShutdown, - } -} diff --git a/crates/yoi/Cargo.toml b/crates/yoi/Cargo.toml index 2cd9653e5..c959c4e70 100644 --- a/crates/yoi/Cargo.toml +++ b/crates/yoi/Cargo.toml @@ -15,7 +15,7 @@ client = { workspace = true } memory = { workspace = true } manifest = { workspace = true } worker = { workspace = true } -workspace-api = { workspace = true } +server-api = { workspace = true } session-store = { workspace = true } session-analytics = { workspace = true } ticket = { workspace = true } diff --git a/crates/yoi/src/objective_cli.rs b/crates/yoi/src/objective_cli.rs index b0fa3bb39..817c104b1 100644 --- a/crates/yoi/src/objective_cli.rs +++ b/crates/yoi/src/objective_cli.rs @@ -148,7 +148,7 @@ fn run_with_backend( return Err(ObjectiveCliError::new("create --title must not be empty")); } let objective = backend - .create_objective(&workspace_api::ObjectiveCreateRequest { + .create_objective(&server_api::ObjectiveCreateRequest { title: title.to_string(), body_md: objective_body_template(), state: "active".to_string(), diff --git a/crates/yoi/src/workspace_bootstrap.rs b/crates/yoi/src/workspace_bootstrap.rs index 898494a9e..6daf87c85 100644 --- a/crates/yoi/src/workspace_bootstrap.rs +++ b/crates/yoi/src/workspace_bootstrap.rs @@ -21,7 +21,7 @@ pub(crate) struct InitOptions { pub(crate) async fn run_init( options: InitOptions, -) -> Result { +) -> Result { let repository_uri = options .repository_root .to_str() @@ -82,8 +82,8 @@ pub(crate) fn discover_repository_root(path: &Path) -> Result, + server_api::WorkspaceSummary, + Vec, )], ) -> Result { let matches = catalog @@ -122,22 +122,21 @@ struct OpenRepositoryIdentity { } impl OpenRepositoryIdentity { - fn matches(&self, source: &workspace_api::RepositorySource) -> bool { + fn matches(&self, source: &server_api::RepositorySource) -> bool { match source.kind { - workspace_api::RepositorySourceKind::LocalPath => fs::canonicalize(&source.uri) + server_api::RepositorySourceKind::LocalPath => fs::canonicalize(&source.uri) .ok() .is_some_and(|path| path == self.root), - workspace_api::RepositorySourceKind::File => source + server_api::RepositorySourceKind::File => source .uri .strip_prefix("file://") .and_then(|path| fs::canonicalize(path).ok()) .is_some_and(|path| path == self.root), - workspace_api::RepositorySourceKind::Ssh - | workspace_api::RepositorySourceKind::Https => self + server_api::RepositorySourceKind::Ssh | server_api::RepositorySourceKind::Https => self .remote_uris .iter() .any(|uri| normalize_git_uri(uri) == normalize_git_uri(&source.uri)), - workspace_api::RepositorySourceKind::Invalid => false, + server_api::RepositorySourceKind::Invalid => false, } } } @@ -306,8 +305,8 @@ mod tests { use super::*; use crate::{ClientDefaultConnection, read_client_config_from_global_path}; - fn workspace_summary(id: &str, name: &str) -> workspace_api::WorkspaceSummary { - workspace_api::WorkspaceSummary { + fn workspace_summary(id: &str, name: &str) -> server_api::WorkspaceSummary { + server_api::WorkspaceSummary { workspace_id: id.to_string(), owner_account_id: "owner-account".to_string(), display_name: name.to_string(), @@ -317,17 +316,15 @@ mod tests { } } - fn repository_summary( - source: workspace_api::RepositorySource, - ) -> workspace_api::RepositorySummary { - workspace_api::RepositorySummary { + fn repository_summary(source: server_api::RepositorySource) -> server_api::RepositorySummary { + server_api::RepositorySummary { repository_key: "main".to_string(), kind: "git".to_string(), provider: "builtin:git".to_string(), source, source_revision: 1, source_fingerprint: "fingerprint".to_string(), - observed_status: workspace_api::RepositoryObservedStatus::Unverified, + observed_status: server_api::RepositoryObservedStatus::Unverified, observed_at: None, default_selector: Some("develop".to_string()), record_authority: "server_db".to_string(), @@ -345,8 +342,8 @@ mod tests { .status() .unwrap(); let identity = discover_open_repository(repository.path()).unwrap(); - let source = workspace_api::RepositorySource { - kind: workspace_api::RepositorySourceKind::LocalPath, + let source = server_api::RepositorySource { + kind: server_api::RepositorySourceKind::LocalPath, uri: identity.root.display().to_string(), }; let matching_repository = repository_summary(source); diff --git a/docs/design/compaction.md b/docs/design/compaction.md index e6dd2b195..6d33041b5 100644 --- a/docs/design/compaction.md +++ b/docs/design/compaction.md @@ -32,6 +32,44 @@ Compaction should preserve persisted reasoning history and avoid serializing unv The important property is explainability: after compaction, records should still show what summary replaced which older context and why future turns can rely on it. +## Activation boundary + +A compacted Segment is staged completely before it can become authoritative. The +activation boundary uses this lock order everywhere a live Worker changes Segment: + +1. stop the compaction service and remove its registry record; +2. acquire the Worker's append barrier; +3. write the final pending-submission checkpoint and stage the replacement Segment; +4. acquire and preflight the machine-wide Worker allocation lock; +5. compare-and-swap `metadata.json` from the source Segment to the replacement; +6. atomically replace the allocation table while still holding its separate lock; +7. publish the append destination, session projection, sink, and in-memory history; +8. release the append barrier. + +Submit and Notify acceptance take the same append barrier. The controller persists +requests received while manual compaction is running immediately rather than holding +volatile deferred methods, so an overlapping Shutdown cannot discard a SubWorker +completion. Consequently an accepted item is either durably included in the +replacement checkpoint, or waits until every live authority points at the replacement +and is then appended there. Restore admission takes the allocation lock, so it cannot +observe the interval between the metadata CAS and allocation hand-off. + +The metadata CAS is the commit point. Failures before it leave the source Segment +active. A failure updating allocation after it marks the old in-memory writer +unusable and retains the machine-wide allocation lock: further acceptance fails +closed and restore admission cannot register a competing writer until process +teardown, after which restore converges from the replacement named by metadata. +The Worker must likewise remain non-idle while terminal compaction-service cleanup +is pending. Cleanup authority is stored on the Worker (not only on one compaction +future). A failed stop returns a typed cleanup-pending outcome without clearing that +authority; the next compaction boundary retries it. The authority and lifecycle +reference are cleared exactly once after the registry record is gone, before +activation or a terminal idle-capable result. While either activation repair or +cleanup attention is outstanding, the controller fences Submit/Notify dispatch and +does not start queued work. Shutdown is itself a cleanup barrier: it retries the +Worker-owned stop authority with backoff and does not emit its terminal event until +the registry record is gone. + ## Metrics and comparison procedure Compaction measurements stay out of the ordinary transcript. They are appended as diff --git a/docs/report/2026-08-19-coder-toolchain-missing-rustfmt.md b/docs/report/2026-08-19-coder-toolchain-missing-rustfmt.md new file mode 100644 index 000000000..907ccd749 --- /dev/null +++ b/docs/report/2026-08-19-coder-toolchain-missing-rustfmt.md @@ -0,0 +1,17 @@ +# Ticket validation image lacks `rustfmt` + +While implementing T-613, `cargo fmt --all` failed before reading project sources because the active Rust toolchain does not provide the `rustfmt` component. The standalone `rustfmt` binary is also absent. + +Observed commands: + +```text +$ cargo fmt --all +error: 'cargo-fmt' is not installed for the toolchain '1.90.0-x86_64-unknown-linux-gnu'. + +$ rustfmt --version +bash: rustfmt: command not found +``` + +This prevents Workers from executing the repository-required formatting check or automatically normalizing Rust changes. The Coder can still run compile/tests and `git diff --check`, but formatting evidence remains weaker and review may surface avoidable formatting differences. + +Suggested improvement: include the `rustfmt` component in the standard coding Workdir/toolchain image and verify `cargo fmt --all -- --check` during image qualification. diff --git a/openapi/server-api.json b/openapi/server-api.json new file mode 100644 index 000000000..4e45d76b0 --- /dev/null +++ b/openapi/server-api.json @@ -0,0 +1,852 @@ +{ + "components": { + "schemas": { + "CreateWorkspaceRepositoryRequest": { + "additionalProperties": false, + "description": "Browser/user intent for registering one Repository in a Workspace.\n\nThe Server parses and canonicalizes `source`; callers cannot assert a\ntransport classification or supply credential material through this DTO.", + "properties": { + "default_ref": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "repository_key": { + "type": "string" + }, + "source": { + "type": "string" + } + }, + "required": [ + "repository_key", + "source" + ], + "type": "object" + }, + "CreateWorkspaceRepositoryResponse": { + "properties": { + "replayed": { + "type": "boolean" + }, + "repository_key": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "repository_key", + "replayed" + ], + "type": "object" + }, + "Diagnostic": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "$ref": "#/components/schemas/DiagnosticSeverity" + } + }, + "required": [ + "code", + "severity", + "message" + ], + "type": "object" + }, + "DiagnosticSeverity": { + "enum": [ + "info", + "warning", + "error" + ], + "type": "string" + }, + "GitRemoteSummary": { + "additionalProperties": false, + "properties": { + "fetch_url": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "fetch_url" + ], + "type": "object" + }, + "GitRepositorySummary": { + "additionalProperties": false, + "properties": { + "branch": { + "type": [ + "string", + "null" + ] + }, + "dirty": { + "type": "boolean" + }, + "head": { + "type": [ + "string", + "null" + ] + }, + "remotes": { + "items": { + "$ref": "#/components/schemas/GitRemoteSummary" + }, + "type": "array" + }, + "status": { + "type": "string" + } + }, + "required": [ + "status", + "dirty", + "remotes" + ], + "type": "object" + }, + "RepositoryApiError": { + "description": "Error body shared by the existing repository routes and their generated adapters.", + "properties": { + "diagnostics": { + "default": [], + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "error": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "error", + "message" + ], + "type": "object" + }, + "RepositoryDetailResponse": { + "additionalProperties": false, + "properties": { + "item": { + "$ref": "#/components/schemas/RepositorySummary" + }, + "source": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "item", + "source" + ], + "type": "object" + }, + "RepositoryDiagnostic": { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "severity": { + "type": "string" + } + }, + "required": [ + "severity", + "code", + "message" + ], + "type": "object" + }, + "RepositoryListResponse": { + "additionalProperties": false, + "properties": { + "diagnostics": { + "items": { + "$ref": "#/components/schemas/Diagnostic" + }, + "type": "array" + }, + "items": { + "items": { + "$ref": "#/components/schemas/RepositorySummary" + }, + "type": "array" + }, + "source": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "items", + "source", + "diagnostics" + ], + "type": "object" + }, + "RepositoryObservedStatus": { + "enum": [ + "unverified", + "ready", + "invalid" + ], + "type": "string" + }, + "RepositorySource": { + "description": "Stable Repository source identity stored by Workspace authority.", + "properties": { + "kind": { + "$ref": "#/components/schemas/RepositorySourceKind" + }, + "uri": { + "description": "Canonical source representation. This is an absolute local path for\n`local_path`, and a normalized URI/remote specification otherwise.", + "type": "string" + } + }, + "required": [ + "kind", + "uri" + ], + "type": "object" + }, + "RepositorySourceKind": { + "description": "Provider-neutral classification of an authoritative Repository source.\n\nLocal paths remain distinct from network Git transports so callers cannot\naccidentally treat an unmaterialized remote as a server-local filesystem path.", + "oneOf": [ + { + "enum": [ + "local_path", + "file", + "ssh", + "https" + ], + "type": "string" + }, + { + "const": "invalid", + "description": "A legacy value that could not be classified during migration. It remains\ninspectable but every provider operation must fail closed. Historical\n`http` wire values decode into this non-executable classification.", + "type": "string" + } + ] + }, + "RepositorySummary": { + "additionalProperties": false, + "properties": { + "default_selector": { + "type": [ + "string", + "null" + ] + }, + "diagnostics": { + "items": { + "$ref": "#/components/schemas/RepositoryDiagnostic" + }, + "type": [ + "array", + "null" + ] + }, + "git": { + "anyOf": [ + { + "$ref": "#/components/schemas/GitRepositorySummary" + }, + { + "type": "null" + } + ] + }, + "kind": { + "type": "string" + }, + "observed_at": { + "type": [ + "string", + "null" + ] + }, + "observed_status": { + "$ref": "#/components/schemas/RepositoryObservedStatus" + }, + "provider": { + "type": "string" + }, + "record_authority": { + "type": "string" + }, + "repository_key": { + "type": "string" + }, + "source": { + "$ref": "#/components/schemas/RepositorySource" + }, + "source_fingerprint": { + "type": "string" + }, + "source_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "repository_key", + "kind", + "provider", + "source", + "source_revision", + "source_fingerprint", + "observed_status", + "record_authority" + ], + "type": "object" + }, + "string": { + "type": "string" + } + }, + "securitySchemes": { + "bearerAuth": { + "scheme": "bearer", + "type": "http" + }, + "browserSession": { + "in": "cookie", + "name": "yoi_workspace_session", + "type": "apiKey" + } + } + }, + "info": { + "title": "Yoi Server API", + "version": "0.1.0", + "x-yoi-source-digest": "sha256:bac8ba9340cf541183e96715dd4ccd8367f965cdcfc398e6d5091f26a903e1ef" + }, + "openapi": "3.1.0", + "paths": { + "/api/repositories": { + "get": { + "operationId": "repository_list_alias", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryListResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/repositories/{repository_key}": { + "get": { + "operationId": "repository_detail_alias", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "repository_key", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryDetailResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/repositories": { + "get": { + "operationId": "repository_list", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryListResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "post": { + "operationId": "repository_create", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkspaceRepositoryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkspaceRepositoryResponse" + } + } + }, + "description": "Alternate successful response" + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWorkspaceRepositoryResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "415": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/repositories/{repository_key}": { + "get": { + "operationId": "repository_detail", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "repository_key", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryDetailResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + } + } +} diff --git a/web/workspace/deno.json b/web/workspace/deno.json index f5c64dd8f..c3a7acf36 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,9 +6,12 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts", + "test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts", "build": "deno run -A npm:vite@7.2.7 build", - "preview": "deno run -A npm:vite@7.2.7 preview" + "preview": "deno run -A npm:vite@7.2.7 preview", + "generate:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types", + "check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check" }, "imports": { "$lib/": "./src/lib/", @@ -27,7 +30,7 @@ "cookie": "npm:cookie@0.6.0", "devalue": "npm:devalue@5.6.4", "set-cookie-parser": "npm:set-cookie-parser@2.7.2", - "shiki": "npm:shiki@3.13.0", + "shiki": "npm:shiki@4.4.2", "svelte": "npm:svelte@5.45.6", "svelte-check": "npm:svelte-check@4.3.4", "typescript": "npm:typescript@5.9.3", diff --git a/web/workspace/deno.lock b/web/workspace/deno.lock index dc902d3a4..43e70c552 100644 --- a/web/workspace/deno.lock +++ b/web/workspace/deno.lock @@ -9,23 +9,28 @@ "npm:@codemirror/state@6.7.1": "6.7.1", "npm:@codemirror/view@6.43.8": "6.43.8", "npm:@fontsource/ibm-plex-mono@5.3.0": "5.3.0", + "npm:@humanspeak/svelte-markdown@1.9.1": "1.9.1_shiki@4.4.2_svelte@5.45.6", "npm:@lezer/common@1.5.2": "1.5.2", "npm:@lezer/highlight@1.2.3": "1.2.3", "npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0", "npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0", "npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0", + "npm:@testing-library/svelte@5.2.8": "5.2.8_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_vitest@3.2.4__happy-dom@18.0.1_happy-dom@18.0.1", "npm:clsx@2.1.1": "2.1.1", "npm:cookie@0.6.0": "0.6.0", "npm:decodal-codemirror@0.3.0": "0.3.0_@codemirror+language@6.12.4_@codemirror+view@6.43.8_@lezer+highlight@1.2.3_@lezer+lr@1.4.10", "npm:devalue@5.6.4": "5.6.4", "npm:gen-interface-jp@0.8.0": "0.8.0", + "npm:happy-dom@18.0.1": "18.0.1", + "npm:marked@18.0.13": "18.0.13", "npm:set-cookie-parser@2.7.2": "2.7.2", - "npm:shiki@3.13.0": "3.13.0", + "npm:shiki@4.4.2": "4.4.2", "npm:sv@*": "0.10.8", "npm:svelte-check@4.3.4": "4.3.4_svelte@5.45.6_typescript@5.9.3", "npm:svelte@5.45.6": "5.45.6", "npm:typescript@5.9.3": "5.9.3", "npm:vite@7.2.7": "7.2.7_yaml@2.9.0", + "npm:vitest@3.2.4": "3.2.4_happy-dom@18.0.1", "npm:yaml@2.9.0": "2.9.0" }, "jsr": { @@ -40,6 +45,20 @@ } }, "npm": { + "@babel/code-frame@7.29.7": { + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dependencies": [ + "@babel/helper-validator-identifier", + "js-tokens@4.0.0", + "picocolors" + ] + }, + "@babel/helper-validator-identifier@7.29.7": { + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==" + }, + "@babel/runtime@7.29.7": { + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==" + }, "@codemirror/autocomplete@6.20.0": { "integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==", "dependencies": [ @@ -217,6 +236,23 @@ "@fontsource/ibm-plex-mono@5.3.0": { "integrity": "sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==" }, + "@humanspeak/memory-cache@1.2.0": { + "integrity": "sha512-im7GIIpgjdD6Fx68xCeiXw3XiDv30/WMRAaodMm+ep0ptYfEUd0cdNeTAylHGIRTJIeByKFHdTmPjAktyDM/JQ==" + }, + "@humanspeak/svelte-markdown@1.9.1_shiki@4.4.2_svelte@5.45.6": { + "integrity": "sha512-2JjpATdZfhGQurV38LtkUkPkyQzM7eXku6LoDBeK6i69qqOe0OLmMwpqtMnhwG3ItdxXOt/3miPbSaNTFqMl5Q==", + "dependencies": [ + "@humanspeak/memory-cache", + "github-slugger", + "htmlparser2", + "marked", + "shiki", + "svelte" + ], + "optionalPeers": [ + "shiki" + ] + }, "@jridgewell/gen-mapping@0.3.13": { "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dependencies": [ @@ -390,44 +426,53 @@ "os": ["win32"], "cpu": ["x64"] }, - "@shikijs/core@3.13.0": { - "integrity": "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA==", + "@shikijs/core@4.4.2": { + "integrity": "sha512-StyzbAyxg2/tBGf78gwbBkGyeQ73lf8UiJArFaQhTQIDqQOCKPCQFanvrs4/Yv3Yfyc+ONInJM6K+FMIf+P+kA==", "dependencies": [ + "@shikijs/primitive", "@shikijs/types", "@shikijs/vscode-textmate", "@types/hast", "hast-util-to-html" ] }, - "@shikijs/engine-javascript@3.13.0": { - "integrity": "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg==", + "@shikijs/engine-javascript@4.4.2": { + "integrity": "sha512-MnIkeqWdVPUWsxlx8gKLVCJFTsqrQJgpTPBPpQwaFeJ56lOnJxj5aN2LUFnfxUEcvOQuNocmbaMnVrCEln6rkw==", "dependencies": [ "@shikijs/types", "@shikijs/vscode-textmate", "oniguruma-to-es" ] }, - "@shikijs/engine-oniguruma@3.13.0": { - "integrity": "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg==", + "@shikijs/engine-oniguruma@4.4.2": { + "integrity": "sha512-GLhowz1+jixjz+wiZ3wMnOn1jTxiFCGl2PkXufivbnwPHKuyw1AYqu5/hbWhZZ2oAb0NP05WUJhYeigY14drnw==", "dependencies": [ "@shikijs/types", "@shikijs/vscode-textmate" ] }, - "@shikijs/langs@3.13.0": { - "integrity": "sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ==", + "@shikijs/langs@4.4.2": { + "integrity": "sha512-8DfeusD+Zdv/eYIDdXyJTUnSMHt+aAWjAOCXV20HNGAHRlInXpG8wh421v6B91WOm9TFwRLN+b/LG5F2NAIojg==", "dependencies": [ "@shikijs/types" ] }, - "@shikijs/themes@3.13.0": { - "integrity": "sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg==", + "@shikijs/primitive@4.4.2": { + "integrity": "sha512-l6fQQKsOMlz72n38fztmSgZ76MO6KSWuw8o+GJ+FhmqrpC9pIOJNQNXGgbb5yX2AwpzlEHwsaLPnk/8o4Fm+rA==", + "dependencies": [ + "@shikijs/types", + "@shikijs/vscode-textmate", + "@types/hast" + ] + }, + "@shikijs/themes@4.4.2": { + "integrity": "sha512-H0CFoL07ddDC2Dd6EdrPYNkRhUR6YCkJlnuYFceYYUJJA5TIm2b5B33qqiDYryBExgbKMndFJPb2u1gTuqO37g==", "dependencies": [ "@shikijs/types" ] }, - "@shikijs/types@3.13.0": { - "integrity": "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw==", + "@shikijs/types@4.4.2": { + "integrity": "sha512-PFYitV4vpDr/iPCIhnHp+Q4ftic5N5VeNJ3KQ1O8gn3h2ar8qgwMAXF7tq4m1CWaMS60fV4VqF6vfnWH4F7vqQ==", "dependencies": [ "@shikijs/vscode-textmate", "@types/hast" @@ -498,14 +543,53 @@ "vitefu" ] }, + "@testing-library/dom@10.4.2": { + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dependencies": [ + "@babel/code-frame", + "@babel/runtime", + "@types/aria-query", + "aria-query@5.3.0", + "dom-accessibility-api", + "lz-string", + "picocolors", + "pretty-format" + ] + }, + "@testing-library/svelte@5.2.8_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_vitest@3.2.4__happy-dom@18.0.1_happy-dom@18.0.1": { + "integrity": "sha512-ucQOtGsJhtawOEtUmbR4rRh53e6RbM1KUluJIXRmh6D4UzxR847iIqqjRtg9mHNFmGQ8Vkam9yVcR5d1mhIHKA==", + "dependencies": [ + "@testing-library/dom", + "svelte", + "vite", + "vitest" + ], + "optionalPeers": [ + "vite", + "vitest" + ] + }, + "@types/aria-query@5.0.4": { + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==" + }, + "@types/chai@5.2.3": { + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dependencies": [ + "@types/deep-eql", + "assertion-error" + ] + }, "@types/cookie@0.6.0": { "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" }, + "@types/deep-eql@4.0.2": { + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==" + }, "@types/estree@1.0.8": { "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" }, - "@types/hast@3.0.4": { - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "@types/hast@3.0.5": { + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", "dependencies": [ "@types/unist" ] @@ -516,9 +600,18 @@ "@types/unist" ] }, + "@types/node@20.19.43": { + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dependencies": [ + "undici-types" + ] + }, "@types/unist@3.0.3": { "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" }, + "@types/whatwg-mimetype@3.0.2": { + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==" + }, "@typescript-eslint/types@8.57.2": { "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==" }, @@ -526,25 +619,114 @@ "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", "deprecated": true }, + "@vitest/expect@3.2.4": { + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dependencies": [ + "@types/chai", + "@vitest/spy", + "@vitest/utils", + "chai", + "tinyrainbow" + ] + }, + "@vitest/mocker@3.2.4_vite@7.2.7__yaml@2.9.0": { + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dependencies": [ + "@vitest/spy", + "estree-walker", + "magic-string", + "vite" + ], + "optionalPeers": [ + "vite" + ] + }, + "@vitest/pretty-format@3.2.4": { + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dependencies": [ + "tinyrainbow" + ] + }, + "@vitest/runner@3.2.4": { + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dependencies": [ + "@vitest/utils", + "pathe", + "strip-literal" + ] + }, + "@vitest/snapshot@3.2.4": { + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dependencies": [ + "@vitest/pretty-format", + "magic-string", + "pathe" + ] + }, + "@vitest/spy@3.2.4": { + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dependencies": [ + "tinyspy" + ] + }, + "@vitest/utils@3.2.4": { + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dependencies": [ + "@vitest/pretty-format", + "loupe", + "tinyrainbow" + ] + }, "acorn@8.16.0": { "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "bin": true }, + "ansi-regex@5.0.1": { + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "ansi-styles@5.2.0": { + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" + }, + "aria-query@5.3.0": { + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dependencies": [ + "dequal" + ] + }, "aria-query@5.3.2": { "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==" }, + "assertion-error@2.0.1": { + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==" + }, "axobject-query@4.1.0": { "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==" }, + "cac@6.7.14": { + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==" + }, "ccount@2.0.1": { "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==" }, + "chai@5.3.3": { + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dependencies": [ + "assertion-error", + "check-error", + "deep-eql", + "loupe", + "pathval" + ] + }, "character-entities-html4@2.1.0": { "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==" }, "character-entities-legacy@3.0.0": { "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==" }, + "check-error@2.1.3": { + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==" + }, "chokidar@4.0.3": { "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dependencies": [ @@ -578,6 +760,9 @@ "@lezer/lr" ] }, + "deep-eql@5.0.2": { + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==" + }, "deepmerge@4.3.1": { "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" }, @@ -593,6 +778,40 @@ "dequal" ] }, + "dom-accessibility-api@0.5.16": { + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==" + }, + "dom-serializer@3.1.1": { + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "dependencies": [ + "domelementtype", + "domhandler", + "entities" + ] + }, + "domelementtype@3.0.0": { + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==" + }, + "domhandler@6.0.1": { + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "dependencies": [ + "domelementtype" + ] + }, + "domutils@4.0.2": { + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "dependencies": [ + "dom-serializer", + "domelementtype", + "domhandler" + ] + }, + "entities@8.1.0": { + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==" + }, + "es-module-lexer@1.7.0": { + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==" + }, "esbuild@0.25.12": { "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "optionalDependencies": [ @@ -636,6 +855,15 @@ "@typescript-eslint/types" ] }, + "estree-walker@3.0.3": { + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dependencies": [ + "@types/estree" + ] + }, + "expect-type@1.4.0": { + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==" + }, "fdir@6.5.0_picomatch@4.0.4": { "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dependencies": [ @@ -653,6 +881,17 @@ "gen-interface-jp@0.8.0": { "integrity": "sha512-7rIytSAfuugVjYithrQseT0zqeUIkJd0LY3Ugdfbk/FxzMSeagcskeZlof5uWJ5liW3Zhn7pdLHq2OIvAftx+A==" }, + "github-slugger@2.0.0": { + "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==" + }, + "happy-dom@18.0.1": { + "integrity": "sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA==", + "dependencies": [ + "@types/node", + "@types/whatwg-mimetype", + "whatwg-mimetype" + ] + }, "hast-util-to-html@9.0.5": { "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", "dependencies": [ @@ -678,24 +917,50 @@ "html-void-elements@3.0.0": { "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==" }, + "htmlparser2@12.0.0": { + "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==", + "dependencies": [ + "domelementtype", + "domhandler", + "domutils", + "entities" + ] + }, "is-reference@3.0.3": { "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", "dependencies": [ "@types/estree" ] }, + "js-tokens@4.0.0": { + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, + "js-tokens@9.0.1": { + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==" + }, "kleur@4.1.5": { "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==" }, "locate-character@3.0.0": { "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==" }, + "loupe@3.2.1": { + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==" + }, + "lz-string@1.5.0": { + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "bin": true + }, "magic-string@0.30.21": { "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dependencies": [ "@jridgewell/sourcemap-codec" ] }, + "marked@18.0.13": { + "integrity": "sha512-xTxVzZsBFwunP6HDmtBkabUQEYArnP7/rMDGmPj9SlrKlQ4i8MdYVow+nJL0eOqwpUqhzBoTBRADGN6uYwPyOw==", + "bin": true + }, "mdast-util-to-hast@13.2.1": { "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "dependencies": [ @@ -750,17 +1015,23 @@ "obug@2.1.1": { "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==" }, - "oniguruma-parser@0.12.1": { - "integrity": "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==" + "oniguruma-parser@0.12.2": { + "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==" }, - "oniguruma-to-es@4.3.4": { - "integrity": "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA==", + "oniguruma-to-es@4.3.6": { + "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==", "dependencies": [ "oniguruma-parser", "regex", "regex-recursion" ] }, + "pathe@2.0.3": { + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" + }, + "pathval@2.0.1": { + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==" + }, "picocolors@1.1.1": { "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" }, @@ -775,9 +1046,20 @@ "source-map-js" ] }, + "pretty-format@27.5.1": { + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dependencies": [ + "ansi-regex", + "ansi-styles", + "react-is" + ] + }, "property-information@7.1.0": { "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==" }, + "react-is@17.0.2": { + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + }, "readdirp@4.1.2": { "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==" }, @@ -840,8 +1122,8 @@ "set-cookie-parser@2.7.2": { "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" }, - "shiki@3.13.0": { - "integrity": "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g==", + "shiki@4.4.2": { + "integrity": "sha512-P8F/dFhRevaw2uSdeIYlq/5SXZNY85DPtmXQ947gD1Zj2JqO5AkNvVVBar0Me9JkFx3uzVud/qOtP5ek9NEQGA==", "dependencies": [ "@shikijs/core", "@shikijs/engine-javascript", @@ -853,6 +1135,9 @@ "@types/hast" ] }, + "siginfo@2.0.0": { + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==" + }, "sirv@3.0.2": { "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", "dependencies": [ @@ -867,6 +1152,12 @@ "space-separated-tokens@2.0.2": { "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==" }, + "stackback@0.0.2": { + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==" + }, + "std-env@3.10.0": { + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==" + }, "stringify-entities@4.0.4": { "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", "dependencies": [ @@ -874,6 +1165,12 @@ "character-entities-legacy" ] }, + "strip-literal@3.1.0": { + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dependencies": [ + "js-tokens@9.0.1" + ] + }, "style-mod@4.1.3": { "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==" }, @@ -902,7 +1199,7 @@ "@sveltejs/acorn-typescript", "@types/estree", "acorn", - "aria-query", + "aria-query@5.3.2", "axobject-query", "clsx", "devalue", @@ -914,6 +1211,12 @@ "zimmerframe" ] }, + "tinybench@2.9.0": { + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==" + }, + "tinyexec@0.3.2": { + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==" + }, "tinyglobby@0.2.15": { "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dependencies": [ @@ -921,6 +1224,15 @@ "picomatch" ] }, + "tinypool@1.1.1": { + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==" + }, + "tinyrainbow@2.0.0": { + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==" + }, + "tinyspy@4.0.6": { + "integrity": "sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==" + }, "totalist@3.0.1": { "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==" }, @@ -931,6 +1243,9 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "bin": true }, + "undici-types@6.21.0": { + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, "unist-util-is@6.0.1": { "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", "dependencies": [ @@ -978,6 +1293,17 @@ "vfile-message" ] }, + "vite-node@3.2.4": { + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dependencies": [ + "cac", + "debug", + "es-module-lexer", + "pathe", + "vite" + ], + "bin": true + }, "vite@7.2.7_yaml@2.9.0": { "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "dependencies": [ @@ -1006,9 +1332,53 @@ "vite" ] }, + "vitest@3.2.4_happy-dom@18.0.1": { + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dependencies": [ + "@types/chai", + "@vitest/expect", + "@vitest/mocker", + "@vitest/pretty-format", + "@vitest/runner", + "@vitest/snapshot", + "@vitest/spy", + "@vitest/utils", + "chai", + "debug", + "expect-type", + "happy-dom", + "magic-string", + "pathe", + "picomatch", + "std-env", + "tinybench", + "tinyexec", + "tinyglobby", + "tinypool", + "tinyrainbow", + "vite", + "vite-node", + "why-is-node-running" + ], + "optionalPeers": [ + "happy-dom" + ], + "bin": true + }, "w3c-keyname@2.2.8": { "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" }, + "whatwg-mimetype@3.0.0": { + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==" + }, + "why-is-node-running@2.3.0": { + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dependencies": [ + "siginfo", + "stackback" + ], + "bin": true + }, "yaml@2.9.0": { "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "bin": true @@ -1037,7 +1407,7 @@ "npm:decodal-codemirror@0.3.0", "npm:devalue@5.6.4", "npm:set-cookie-parser@2.7.2", - "npm:shiki@3.13.0", + "npm:shiki@4.4.2", "npm:svelte-check@4.3.4", "npm:svelte@5.45.6", "npm:typescript@5.9.3", @@ -1046,7 +1416,12 @@ "packageJson": { "dependencies": [ "npm:@fontsource/ibm-plex-mono@5.3.0", + "npm:@humanspeak/svelte-markdown@1.9.1", + "npm:@testing-library/svelte@5.2.8", "npm:gen-interface-jp@0.8.0", + "npm:happy-dom@18.0.1", + "npm:shiki@4.4.2", + "npm:vitest@3.2.4", "npm:yaml@2.9.0" ] } diff --git a/web/workspace/package.json b/web/workspace/package.json index 21d7a9c31..d158b403a 100644 --- a/web/workspace/package.json +++ b/web/workspace/package.json @@ -5,7 +5,14 @@ "type": "module", "dependencies": { "@fontsource/ibm-plex-mono": "5.3.0", + "@humanspeak/svelte-markdown": "1.9.1", "gen-interface-jp": "0.8.0", + "shiki": "4.4.2", "yaml": "2.9.0" + }, + "devDependencies": { + "@testing-library/svelte": "5.2.8", + "happy-dom": "18.0.1", + "vitest": "3.2.4" } } diff --git a/web/workspace/src/lib/generated/README.md b/web/workspace/src/lib/generated/README.md new file mode 100644 index 000000000..18d58e70d --- /dev/null +++ b/web/workspace/src/lib/generated/README.md @@ -0,0 +1,20 @@ +# Generated frontend contracts + +Checked-in files in this directory are generated API contracts. Do not edit them by hand. + +## Repository list/detail/create API + +`repository-api.ts` is the only TypeScript declaration authority for the Repository list, detail, and create request/response/error wire types. It is generated from the schema closure of the five Repository operations in the canonical `openapi/server-api.json` artifact. The generated header pins the in-repository generator name/version/options, canonical input path and digest, and output path. + +Regenerate or verify it from the repository root: + +```sh +cargo run -q -p server-api --example generate_repository_openapi_types +cargo run -q -p server-api --example generate_repository_openapi_types -- --check +``` + +The generator fails closed on unsupported or lossy OpenAPI constructs, including ambiguous nullable unions, external references, semantic `$ref` siblings, unimplemented ordinary schema keywords, unsupported enum literals/formats, and integer ranges that cannot be represented safely by JavaScript numbers. Its one explicit format mapping is canonical `uint64` to a nonnegative JavaScript safe `number`; the runtime parser enforces the same range. The legacy TypeScript generator imports the two shared Repository source projection types it still needs and no longer declares the migrated Repository list/detail types. + +## Other generated contracts + +The remaining files retain their generator command in their header. `legacy-server-api.ts` is transitional: new bounded domain slices should use a focused contract artifact instead of adding another API surface to it. diff --git a/web/workspace/src/lib/generated/auth-api.ts b/web/workspace/src/lib/generated/auth-api.ts index 9fa3291ff..2233ccf1f 100644 --- a/web/workspace/src/lib/generated/auth-api.ts +++ b/web/workspace/src/lib/generated/auth-api.ts @@ -1,107 +1,50 @@ -// Generated from workspace-api. Do not edit by hand. -// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_auth_api_types > web/workspace/src/lib/generated/auth-api.ts +// Generated from server-api. Do not edit by hand. +// Regenerate: cargo run -q -p server-api --features typescript --example generate_auth_api_types > web/workspace/src/lib/generated/auth-api.ts -export type AuthPublicConfig = { - rp_id: string; - origin: string; - public_base_url: string; - cookie_name: string; -}; +export type AuthPublicConfig = { rp_id: string, origin: string, public_base_url: string, cookie_name: string, }; export type ActorAuthMethod = "browser_session" | "api_token"; -export type AuthenticatedUser = { - user_id: string; - account_id: string; - handle: string; - display_name: string; -}; +export type AuthenticatedUser = { user_id: string, account_id: string, handle: string, display_name: string, }; -export type RequestActor = { - user_id: string; - account_id: string; - handle: string; - display_name: string; - auth_method: ActorAuthMethod; -}; +export type RequestActor = { user_id: string, account_id: string, handle: string, display_name: string, auth_method: ActorAuthMethod, }; -export type WhoamiResponse = { actor: RequestActor | null }; +export type WhoamiResponse = { actor: RequestActor | null, }; -export type AuthBootstrapUserRequest = { - handle: string; - display_name?: string | null; -}; +export type AuthBootstrapUserRequest = { handle: string, display_name?: string | null, }; -export type AuthUserResponse = { user: AuthenticatedUser }; +export type AuthUserResponse = { user: AuthenticatedUser, }; -export type PasskeyRegistrationOptionsRequest = { - handle: string; - display_name?: string | null; - browser_origin?: string | null; -}; +export type PasskeyRegistrationOptionsRequest = { handle: string, display_name?: string | null, browser_origin?: string | null, }; -export type PasskeyRegistrationOptionsResponse = { - challenge_id: string; - public_key: unknown; -}; +export type PasskeyRegistrationOptionsResponse = { challenge_id: string, public_key: unknown, }; -export type PasskeyRegistrationCompleteRequest = { - challenge_id: string; - credential: unknown; -}; +export type PasskeyRegistrationCompleteRequest = { challenge_id: string, credential: unknown, }; -export type PasskeyLoginOptionsRequest = { - handle?: string | null; - browser_origin?: string | null; -}; +export type PasskeyLoginOptionsRequest = { handle?: string | null, browser_origin?: string | null, }; -export type PasskeyLoginOptionsResponse = { - challenge_id: string; - public_key: unknown; -}; +export type PasskeyLoginOptionsResponse = { challenge_id: string, public_key: unknown, }; -export type PasskeyLoginCompleteRequest = { - challenge_id: string; - credential: unknown; -}; +export type PasskeyLoginCompleteRequest = { challenge_id: string, credential: unknown, }; -export type DeviceLoginStartRequest = { client_name?: string | null }; +export type DeviceLoginStartRequest = { client_name?: string | null, }; -export type DeviceLoginStartResponse = { - device_code: string; - user_code: string; - verification_uri: string; - verification_uri_complete: string; - expires_in: number; - interval: number; -}; +export type DeviceLoginStartResponse = { device_code: string, user_code: string, verification_uri: string, verification_uri_complete: string, expires_in: number, interval: number, }; -export type DeviceLoginApproveRequest = { user_code: string }; +export type DeviceLoginApproveRequest = { user_code: string, }; export type DeviceLoginApprovalStatus = "approved"; -export type DeviceLoginApproveResponse = { - status: DeviceLoginApprovalStatus; - user: AuthenticatedUser; -}; +export type DeviceLoginApproveResponse = { status: DeviceLoginApprovalStatus, user: AuthenticatedUser, }; -export type DeviceLoginPollRequest = { device_code: string }; +export type DeviceLoginPollRequest = { device_code: string, }; export type DeviceAccessTokenType = "Bearer"; -export type DeviceLoginPollStatus = - | "pending" - | "approved" - | "expired" - | "denied" - | "consumed"; +export type DeviceLoginPollStatus = "pending" | "approved" | "expired" | "denied" | "consumed"; -export type DeviceLoginPollResponse = { - status: DeviceLoginPollStatus; - access_token?: string | null; - token_type?: DeviceAccessTokenType | null; -}; +export type DeviceLoginPollResponse = { status: DeviceLoginPollStatus, access_token?: string | null, token_type?: DeviceAccessTokenType | null, }; export type LogoutStatus = "logged_out"; -export type LogoutResponse = { status: LogoutStatus }; +export type LogoutResponse = { status: LogoutStatus, }; diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index ad25a30ed..76cc6231c 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,64 +1,30 @@ -// Generated by `cargo run -p workspace-api --features typescript --example generate_companion_api_types`. +// Generated by `cargo run -p server-api --features typescript --example generate_companion_api_types`. // Do not edit manually. export type DiagnosticSeverity = "info" | "warning" | "error"; -export type Diagnostic = { - code: string; - severity: DiagnosticSeverity; - message: string; -}; +export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, }; -export type WorkspaceWorkerSubject = { - "kind": "runtime_worker"; - runtime_id: string; - worker_id: string; -}; +export type WorkspaceWorkerSubject = { "kind": "runtime_worker", runtime_id: string, worker_id: string, }; -export type WorkspaceWorkerDiscoveryItem = { - subject: WorkspaceWorkerSubject; - resource_key: string; - display_name: string; - profile: string | null; - status?: string | null; -}; +export type WorkspaceWorkerDiscoveryItem = { subject: WorkspaceWorkerSubject, resource_key: string, display_name: string, profile: string | null, status?: string | null, }; export type CompanionLifecycleState = "idle" | "running" | "stopped"; export type CompanionMessageDisposition = "accepted" | "rejected"; -export type CompanionTransportSummary = { mode: string; available: boolean }; +export type CompanionTransportSummary = { mode: string, available: boolean, }; -export type CompanionStatusResponse = { - state: CompanionLifecycleState; - worker: WorkspaceWorkerDiscoveryItem | null; - transport: CompanionTransportSummary; - diagnostics: Array; -}; +export type CompanionStatusResponse = { state: CompanionLifecycleState, worker: WorkspaceWorkerDiscoveryItem | null, transport: CompanionTransportSummary, diagnostics: Array, }; -export type CompanionMessageRequest = { content: string }; +export type CompanionMessageRequest = { content: string, }; -export type CompanionCancelRequest = { reason?: string | null }; +export type CompanionCancelRequest = { reason?: string | null, }; -export type CompanionMessageResponse = { - state: CompanionMessageDisposition; - message: string; -}; +export type CompanionMessageResponse = { state: CompanionMessageDisposition, message: string, }; export type CompanionTranscriptRole = "user" | "assistant"; -export type CompanionTranscriptItem = { - sequence: number; - role: CompanionTranscriptRole; - content: string; - created_at: string; -}; +export type CompanionTranscriptItem = { sequence: number, role: CompanionTranscriptRole, content: string, created_at: string, }; -export type CompanionTranscriptProjection = { - state: CompanionLifecycleState; - start: number; - limit: number; - total: number; - next: number | null; - items: Array; -}; +export type CompanionTranscriptProjection = { state: CompanionLifecycleState, start: number, limit: number, total: number, next: number | null, items: Array, }; diff --git a/web/workspace/src/lib/generated/legacy-server-api.ts b/web/workspace/src/lib/generated/legacy-server-api.ts new file mode 100644 index 000000000..7c4b0b9df --- /dev/null +++ b/web/workspace/src/lib/generated/legacy-server-api.ts @@ -0,0 +1,140 @@ +// This file is generated by `cargo run -p server-api --features typescript --example generate_legacy_typescript | deno fmt -`. +// Do not edit this file directly. + +import type { RepositoryObservedStatus, RepositorySource } from "./repository-api"; + +export type WorkspaceSummary = { workspace_id: string, owner_account_id: string, display_name: string, state: string, created_at: string, updated_at: string, }; + +export type WorkspaceCatalogListResponse = Array; + +export type WorkspaceRepositoryRecord = { workspace_id: string, repository_key: string, kind: string, provider: string | null, source: RepositorySource, default_ref: string | null, source_revision: number, source_fingerprint: string, observed_status: RepositoryObservedStatus, observed_at: string | null, created_at: string, updated_at: string, }; + +export type WorkspaceCreateResponse = { workspace: WorkspaceSummary, repository: WorkspaceRepositoryRecord, config_revision: number, request_fingerprint: string, replayed: boolean, }; + +export type WorkspaceAuthConfig = { "Passkey": { rp_id: string, origin: string, public_base_url: string, cookie_name: string, } }; + +export type WorkspacePermissionSummary = { manage_repositories: boolean, manage_secrets: boolean, manage_runtimes: boolean, delete_workspace: boolean, }; + +export type WorkspaceDeletionState = "queued" | "running" | "blocked" | "failed" | "succeeded"; + +export type WorkspaceDeletionBlockerKind = "last_accessible_workspace" | "revision_conflict" | "dirty_workdir" | "worker_removal_blocked" | "workdir_removal_blocked" | "retention_hold" | "cleanup_unavailable"; + +export type WorkspaceDeletionBlocker = { kind: WorkspaceDeletionBlockerKind, resource_kind: string | null, resource_key: string | null, message: string, }; + +export type WorkspaceDeletionResourceCounts = { workers: number, workdirs: number, repositories: number, runtime_bindings: number, secrets: number, artifacts: number, }; + +export type WorkspaceDeletionPreflightResponse = { workspace_id: string, display_name: string, +/** + * Opaque persisted Workspace metadata revision used as a CAS fence. + */ +expected_revision: string, can_delete: boolean, resources: WorkspaceDeletionResourceCounts, blockers: Array, }; + +export type WorkspaceDeletionRequest = { operation_id: string, expected_revision: string, confirmation: string, }; + +export type WorkspaceDeletionOperationResponse = { operation_id: string, workspace_id: string, display_name: string, state: WorkspaceDeletionState, resources: WorkspaceDeletionResourceCounts, child_operation_ids: Array, blockers: Array, failure_category: string | null, created_at: string, updated_at: string, completed_at: string | null, }; + +export type DiagnosticSeverity = "info" | "warning" | "error"; + +export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, }; + +export type WorkspaceExtensionPointState = { status: string, note: string, diagnostics: Array, }; + +export type WorkspaceExtensionPoints = { store: string, event_stream: WorkspaceExtensionPointState, host_worker_bridge: WorkspaceExtensionPointState, companion_console: WorkspaceExtensionPointState, }; + +export type WorkspaceResponse = { workspace_id: string, display_name: string, record_authority: string, schema_version: number, auth: WorkspaceAuthConfig, permissions: WorkspacePermissionSummary, extension_points: WorkspaceExtensionPoints, }; + +export type WorkspaceMetadataSettingsResponse = { workspace_id: string, display_name: string, created_at: string, revision: string, source: string, diagnostics: Array, }; + +export type UpdateWorkspaceMetadataRequest = { display_name: string, revision: string, }; + +export type WorkspaceMetadataMutationResponse = { workspace: WorkspaceMetadataSettingsResponse, diagnostics: Array, }; + +export type WorkspaceSigningIdentityState = "pending_provisioning" | "active"; + +export type WorkspaceSigningIdentityPublic = { workspace_id: string, key_id: string, algorithm: string, public_key?: string, public_key_fingerprint?: string, revision: number, state: WorkspaceSigningIdentityState, created_at: string, provisioned_at?: string, }; + +export type WorkspacePublicIdentityBundle = { workspace_id: string, backend_url: string, key_id: string, algorithm: string, public_key: string, public_key_fingerprint: string, revision: number, }; + +export type WorkspaceSigningIdentityResponse = { identity: WorkspaceSigningIdentityPublic, public_bundle?: WorkspacePublicIdentityBundle, }; + +export type ProfileSettingsResponse = { workspace_id: string, registry_revision: string, config_revision?: number | null, tree_digest?: string | null, projection_digest?: string | null, default_profile?: string | null, profiles: Array, sources: Array, diagnostics: Array, }; + +export type WorkspaceProfileSummary = { profile_id: string, selector: string, label: string, source_kind: string, profile_source_id?: string | null, description?: string | null, editable: boolean, is_default: boolean, diagnostics: Array, }; + +export type WorkspaceProfileSourceSummary = { profile_source_id: string, display_path: string, kind: string, content_type: string, content_digest: string, provenance: WorkspaceProfileSourceProvenance, editable: boolean, revision: string, size_bytes: number, diagnostics: Array, }; + +export type WorkspaceProfileSourceProvenance = "project_profile_source_tree"; + +export type GitCommitSummary = { hash: string, short_hash: string, summary: string, author_name: string, author_email: string, author_date: string, parents: Array, refs: Array, }; + +export type RepositorySshConnectionProbeRequest = { runtime_id: string, }; + +export type RepositorySshHostKeyCandidate = { algorithm: string, host_key: string, fingerprint: string, }; + +export type RepositorySshConnectionTrustState = "untrusted" | "verified" | "changed"; + +export type RepositorySshConnectionProbeResponse = { workspace_id: string, repository_key: string, runtime_id: string, hostname: string, port: number, trust_state: RepositorySshConnectionTrustState, host_trust_id: string, expected_host_trust_revision: number | null, candidates: Array, }; + +export type ConfirmRepositorySshHostTrustRequest = { operation_id: string, runtime_id: string, host_key: string, expected_host_trust_revision: number | null, }; + +export type RepositoryLogResponse = { workspace_id: string, repository_key: string, default_selector?: string | null, limit: number, items: Array, diagnostics: Array, }; + +export type RuntimeSourceKind = "embedded_worker_runtime" | "remote_http"; + +export type RuntimeSourceStatus = "active" | "reserved"; + +export type RuntimeIdentityAuthority = "runtime_registry_projection" | "server_runtime_configuration"; + +export type RuntimeSourceSummary = { kind: RuntimeSourceKind, status: RuntimeSourceStatus, identity_authority: RuntimeIdentityAuthority, note: string, }; + +export type RuntimeSummary = { runtime_id: string, label: string, kind: string, status: string, source: RuntimeSourceSummary, host_ids: Array, worker_creation_available: boolean, os: string, arch: string, diagnostics: Array, }; + +export type WorkspaceRuntimeBindingState = "configured" | "verified" | "revoked"; + +export type RuntimeConnectionDisplayState = "configured" | "verified" | "unavailable" | "revoked"; + +export type RuntimeVerificationOutcome = "verified" | "challenge_issued" | "verification_failed" | "connectivity_failed"; + +export type RuntimeVerificationEvidenceSummary = { verified_at: string | null, last_checked_at: string, last_outcome: RuntimeVerificationOutcome, binding_revision: number, workspace_key_id: string, workspace_identity_revision: number, workspace_trust_generation: number, runtime_public_key_fingerprint: string, runtime_identity_revision: number, }; + +export type WorkspaceRuntimeBindingSummary = { state: WorkspaceRuntimeBindingState, connection_state: RuntimeConnectionDisplayState, revision: number, workspace_key_id?: string | null, workspace_key_generation?: number | null, verification?: RuntimeVerificationEvidenceSummary | null, }; + +export type RuntimeManagementSummary = { built_in: boolean, config_managed: boolean, removable: boolean, endpoint_configured: boolean, token_ref_configured: boolean, binding?: WorkspaceRuntimeBindingSummary | null, }; + +export type WorkspaceRuntimeResource = { management: RuntimeManagementSummary, runtime_id: string, label: string, kind: string, status: string, source: RuntimeSourceSummary, host_ids: Array, worker_creation_available: boolean, os: string, arch: string, diagnostics: Array, }; + +export type RuntimeTrustKeyStatus = "unconfigured" | "active" | "revoked"; + +export type RuntimeTrustKeyState = { status: RuntimeTrustKeyStatus, fingerprint?: string | null, revision?: number | null, created_at?: string | null, updated_at?: string | null, revoked_at?: string | null, }; + +export type RuntimeTrustAuditAction = "created" | "replaced" | "reactivated" | "revoked"; + +export type RuntimeTrustAuditEntry = { action: RuntimeTrustAuditAction, actor_account_id: string, old_fingerprint?: string | null, new_fingerprint?: string | null, revision: number, at: string, }; + +export type WorkspaceRuntimeDetail = { workspace_id: string, runtime: WorkspaceRuntimeResource, endpoint?: string | null, trust_key: RuntimeTrustKeyState, recent_audit: Array, }; + +export type RuntimeTrustKeyRevealResponse = { public_key: string, }; + +export type RevokeRuntimeTrustKeyRequest = { expected_revision: number, }; + +export type RemoveRuntimeRequest = { operation_id: string, expected_binding_revision: number, }; + +export type RuntimeRemovalOperationState = "pending" | "cleanup_pending" | "succeeded" | "failed"; + +export type RuntimeRemovalOperationResponse = { operation_id: string, workspace_id: string, runtime_id: string, state: RuntimeRemovalOperationState, binding_removed: boolean, runtime_registration_removed: boolean | null, failure_category?: string | null, created_at: string, updated_at: string, completed_at?: string | null, }; + +export type RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use"; + +export type RuntimeTrustConflictResponse = { error: RuntimeTrustConflictKind, message: string, current_revision?: number, current_fingerprint?: string | null, }; + +export type RuntimePublicIdentityBundle = { identity_id: string, public_key: string, }; + +export type CreateRemoteRuntimeRequest = { public_bundle: RuntimePublicIdentityBundle, display_name?: string | null, endpoint: string, expected_revision?: number | null, }; + +export type UpdateRemoteRuntimeRequest = { display_name?: string | null, endpoint: string, }; + +export type RuntimeConnectionTestStatus = "compatible" | "failed"; + +export type RuntimeConnectionTestFailureKind = "authentication" | "authorization" | "network_unreachable" | "timeout" | "tls_or_transport" | "malformed_response" | "protocol_version_mismatch" | "runtime_identity_mismatch" | "configuration"; + +export type RuntimeConnectionTestResponse = { workspace_id: string, runtime_id: string, binding_revision: number, connection_state: RuntimeConnectionDisplayState, verification: RuntimeVerificationEvidenceSummary | null, checked_at: string, status: RuntimeConnectionTestStatus, failure_kind: RuntimeConnectionTestFailureKind | null, expected_protocol_version: number, actual_protocol_version: number | null, diagnostics: Array, }; diff --git a/web/workspace/src/lib/generated/memory-api.ts b/web/workspace/src/lib/generated/memory-api.ts index f62472801..d1fd56c6d 100644 --- a/web/workspace/src/lib/generated/memory-api.ts +++ b/web/workspace/src/lib/generated/memory-api.ts @@ -1,100 +1,26 @@ -// Generated from workspace-api. Do not edit by hand. -// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts +// Generated from server-api. Do not edit by hand. +// Regenerate: cargo run -q -p server-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts export type DiagnosticSeverity = "info" | "warning" | "error"; -export type Diagnostic = { - code: string; - severity: DiagnosticSeverity; - message: string; -}; +export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, }; -export type MemoryDocumentResponse = { - body_md: string; - created_at: string; - updated_at: string; - bytes: number; - record_source: string; -}; +export type MemoryDocumentResponse = { body_md: string, created_at: string, updated_at: string, bytes: number, record_source: string, }; -export type MemoryCandidateKind = - | "preference" - | "working_assumption" - | "constraint" - | "decision" - | "open_question" - | "lesson"; +export type MemoryCandidateKind = "preference" | "working_assumption" | "constraint" | "decision" | "open_question" | "lesson"; -export type MemoryEvidenceOriginKind = - | "human_input" - | "worker_input" - | "flow_instruction" - | "backend_instruction" - | "model_output" - | "tool_output" - | "derived_summary" - | "legacy_unknown"; +export type MemoryEvidenceOriginKind = "human_input" | "worker_input" | "flow_instruction" | "backend_instruction" | "model_output" | "tool_output" | "derived_summary" | "legacy_unknown"; -export type MemoryEvidenceOrigin = { - kind: MemoryEvidenceOriginKind; - account_id?: string | null; - workspace_id?: string | null; - runtime_id?: string | null; - worker_id?: string | null; - flow_selector?: string | null; - flow_definition_id?: string | null; - flow_definition_revision?: number | null; -}; +export type MemoryEvidenceOrigin = { kind: MemoryEvidenceOriginKind, account_id?: string | null, workspace_id?: string | null, runtime_id?: string | null, worker_id?: string | null, flow_selector?: string | null, flow_definition_id?: string | null, flow_definition_revision?: number | null, }; -export type MemorySourceRef = { segment_id: string; range: [number, number] }; +export type MemorySourceRef = { segment_id: string, range: [number, number], }; -export type MemoryStagingEvidence = { - id: string; - kind: string; - entry_range: [number, number] | null; - origin?: MemoryEvidenceOrigin | null; - excerpt: string | null; - summary: string | null; -}; +export type MemoryStagingEvidence = { id: string, kind: string, entry_range: [number, number] | null, origin?: MemoryEvidenceOrigin | null, excerpt: string | null, summary: string | null, }; -export type MemorySourceEvidenceRef = { - session_id: string | null; - segment_id: string | null; - entry_range: [number, number] | null; - evidence_id: string | null; - origin?: MemoryEvidenceOrigin | null; - evidence_kind: string | null; - label: string | null; - summary: string | null; -}; +export type MemorySourceEvidenceRef = { session_id: string | null, segment_id: string | null, entry_range: [number, number] | null, evidence_id: string | null, origin?: MemoryEvidenceOrigin | null, evidence_kind: string | null, label: string | null, summary: string | null, }; -export type MemoryStagingRecord = { - schema_version: number; - id: string; - extract_run_id: string; - source: MemorySourceRef; - kind: MemoryCandidateKind; - claim: string; - why_useful: string; - staleness: string | null; - evidence: Array; - source_refs: Array; -}; +export type MemoryStagingRecord = { schema_version: number, id: string, extract_run_id: string, source: MemorySourceRef, kind: MemoryCandidateKind, claim: string, why_useful: string, staleness: string | null, evidence: Array, source_refs: Array, }; -export type MemoryStagingEntry = { - id: string; - byte_len: number; - record: MemoryStagingRecord; -}; +export type MemoryStagingEntry = { id: string, byte_len: number, record: MemoryStagingRecord, }; -export type MemoryStagingListResponse = { - limit: number; - returned_count: number; - total_valid_count: number; - invalid_count: number; - truncated: boolean; - order: string; - record_authority: string; - items: Array; - diagnostics: Array; -}; +export type MemoryStagingListResponse = { limit: number, returned_count: number, total_valid_count: number, invalid_count: number, truncated: boolean, order: string, record_authority: string, items: Array, diagnostics: Array, }; diff --git a/web/workspace/src/lib/generated/protocol.ts b/web/workspace/src/lib/generated/protocol.ts index 8e1f8ed7e..c96d3b430 100644 --- a/web/workspace/src/lib/generated/protocol.ts +++ b/web/workspace/src/lib/generated/protocol.ts @@ -232,6 +232,8 @@ export type SubscriptionWorkerIds = Array; export type SubscriptionWorkerState = "idle" | "running" | "paused" | "stopped"; +export type SubscriptionWorkerAvailability = "observed" | "unavailable"; + export type EventSubscriptionSelector = { "topic": "runtime_workers" } | { "topic": "worker_lifecycle", worker_ids: SubscriptionWorkerIds, } | { "topic": "worker_protocol", worker_id: SubscriptionWorkerId, runtime_id?: string | null, } | { "topic": "workspace_workers" } | { "topic": "workspace_workdirs" }; export type SubscriptionWorker = { worker_id: SubscriptionWorkerId, @@ -245,6 +247,11 @@ runtime_id?: string | null, * Workspace-facing projections must populate it before publishing the Worker. */ resource_key?: string | null, +/** + * Freshness of the Runtime-backed observation carried by this projection. + * `Unavailable` preserves catalog membership without claiming that execution stopped. + */ +availability: SubscriptionWorkerAvailability, /** * Producer-owned monotonic revision for this Worker subject. */ @@ -286,7 +293,7 @@ export type SubscriptionFramePayload = { "frame": "request", "message": Subscrip export type SubscriptionFrame = { protocol_version: number, } & ({ "frame": "request", "message": SubscriptionRequest } | { "frame": "response", "message": SubscriptionResponse } | { "frame": "event", "message": SubscriptionEvent } | { "frame": "worker_protocol", "message": SubscriptionWorkerProtocolMethod }); -export type Method = { "method": "submit", "params": { submission_request_id: string, input: Array, } } | { "method": "notify", "params": { notification_request_id: string, message: string, auto_run?: boolean, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "list_pending_submissions" } | { "method": "cancel_pending_submission", "params": { submission_id: string, expected_revision: number, } } | { "method": "clear_pending_submissions", "params": { expected_revision: number, } } | { "method": "continue_pending", "params": { expected_revision: number, expected_head_id: string, } } | { "method": "resume", "params": { command: WorkerCommandEnvelope, } } | { "method": "cancel", "params": { command: WorkerCommandEnvelope, } } | { "method": "pause", "params": { command: WorkerCommandEnvelope, } } | { "method": "compact", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } }; +export type Method = { "method": "submit", "params": { submission_request_id: string, input: Array, } } | { "method": "notify", "params": { notification_request_id: string, message: string, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "list_pending_submissions" } | { "method": "cancel_pending_submission", "params": { submission_id: string, expected_revision: number, } } | { "method": "clear_pending_submissions", "params": { expected_revision: number, } } | { "method": "continue_pending", "params": { expected_revision: number, expected_head_id: string, } } | { "method": "resume", "params": { command: WorkerCommandEnvelope, } } | { "method": "cancel", "params": { command: WorkerCommandEnvelope, } } | { "method": "pause", "params": { command: WorkerCommandEnvelope, } } | { "method": "compact", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } }; export type Event = { "event": "submission_accepted", "data": { submission_request_id: string, submission_id: string, disposition: SubmissionDisposition, } } | { "event": "submission_rejected", "data": { submission_request_id: string, message: string, } } | { "event": "pending_submissions_changed", "data": { pending: PendingSubmissionsSnapshot, } } | { "event": "user_message", "data": { segments: Array, } } | { "event": "system_item", "data": { item: unknown, } } | { "event": "invoke_start", "data": { kind: InvokeKind, } } | { "event": "turn_start", "data": { turn: number, } } | { "event": "turn_end", "data": { turn: number, result: TurnResult, } } | { "event": "llm_call_start", "data": { llm_call: number, } } | { "event": "llm_call_end", "data": { llm_call: number, } } | { "event": "llm_retry", "data": { llm_call: number, /** diff --git a/web/workspace/src/lib/generated/repository-access-api.ts b/web/workspace/src/lib/generated/repository-access-api.ts index 958c76d7f..2d0eb9d07 100644 --- a/web/workspace/src/lib/generated/repository-access-api.ts +++ b/web/workspace/src/lib/generated/repository-access-api.ts @@ -1,93 +1,26 @@ -// Generated from workspace-api. Do not edit by hand. -// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts +// Generated from server-api. Do not edit by hand. +// Regenerate: cargo run -q -p server-api --features typescript --example generate_repository_access_types > web/workspace/src/lib/generated/repository-access-api.ts -export type RepositorySshCredential = { - credential_id: string; - workspace_id: string; - name: string; - public_key_algorithm: string; - public_key_fingerprint: string; - current_revision: number; - status: string; - created_at: string; - rotated_at: string | null; - referenced_repositories: Array; -}; +export type RepositorySshCredential = { credential_id: string, workspace_id: string, name: string, public_key_algorithm: string, public_key_fingerprint: string, current_revision: number, status: string, created_at: string, rotated_at: string | null, referenced_repositories: Array, }; -export type CreateRepositorySshCredentialRequest = { - operation_id: string; - credential_id: string; - name: string; - private_key: string; - passphrase: string | null; -}; +export type CreateRepositorySshCredentialRequest = { operation_id: string, credential_id: string, name: string, private_key: string, passphrase: string | null, }; -export type GenerateRepositorySshCredentialRequest = { - operation_id: string; - credential_id: string; - name: string; -}; +export type GenerateRepositorySshCredentialRequest = { operation_id: string, credential_id: string, name: string, }; -export type RepositorySshPublicKey = { - credential_id: string; - current_revision: number; - public_key_algorithm: string; - public_key_fingerprint: string; - public_key: string; -}; +export type RepositorySshPublicKey = { credential_id: string, current_revision: number, public_key_algorithm: string, public_key_fingerprint: string, public_key: string, }; -export type RotateRepositorySshCredentialRequest = { - operation_id: string; - expected_revision: number; - private_key: string; - passphrase: string | null; -}; +export type RotateRepositorySshCredentialRequest = { operation_id: string, expected_revision: number, private_key: string, passphrase: string | null, }; -export type DeleteRepositorySshCredentialRequest = { - operation_id: string; - expected_revision: number; -}; +export type DeleteRepositorySshCredentialRequest = { operation_id: string, expected_revision: number, }; -export type RepositorySshHostTrust = { - host_trust_id: string; - workspace_id: string; - hostname: string; - port: number; - key_algorithm: string; - host_key: string; - fingerprint: string; - current_revision: number; - created_at: string; - updated_at: string; - referenced_repositories: Array; -}; +export type RepositorySshHostTrust = { host_trust_id: string, workspace_id: string, hostname: string, port: number, key_algorithm: string, host_key: string, fingerprint: string, current_revision: number, created_at: string, updated_at: string, referenced_repositories: Array, }; -export type PutRepositorySshHostTrustRequest = { - operation_id: string; - host_trust_id: string; - hostname: string; - port: number; - host_key: string; - expected_revision: number | null; -}; +export type PutRepositorySshHostTrustRequest = { operation_id: string, host_trust_id: string, hostname: string, port: number, host_key: string, expected_revision: number | null, }; -export type DeleteRepositorySshHostTrustRequest = { - operation_id: string; - expected_revision: number; -}; +export type DeleteRepositorySshHostTrustRequest = { operation_id: string, expected_revision: number, }; export type RepositoryAccessMode = "read_only" | "read_write"; -export type RepositorySshAccessBinding = { - repository_key: string; - credential_id: string; - host_trust_id: string; - access: RepositoryAccessMode; -}; +export type RepositorySshAccessBinding = { repository_key: string, credential_id: string, host_trust_id: string, access: RepositoryAccessMode, }; -export type RepositoryAccessProjection = { - workspace_id: string; - config_revision: number; - projection_digest: string; - bindings: Array; -}; +export type RepositoryAccessProjection = { workspace_id: string, config_revision: number, projection_digest: string, bindings: Array, }; diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts new file mode 100644 index 000000000..aa6a289fa --- /dev/null +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -0,0 +1,34 @@ +// @generated by yoi-repository-openapi-typescript@1. Do not edit. +// Input: openapi/server-api.json +// Input contract: 0.1.0 (sha256:bac8ba9340cf541183e96715dd4ccd8367f965cdcfc398e6d5091f26a903e1ef) +// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output +// Output: web/workspace/src/lib/generated/repository-api.ts +// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check + +export type CreateWorkspaceRepositoryRequest = { default_ref?: string | null; repository_key: string; source: string; }; + +export type CreateWorkspaceRepositoryResponse = { replayed: boolean; repository_key: string; workspace_id: string; }; + +export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; }; + +export type DiagnosticSeverity = "info" | "warning" | "error"; + +export type GitRemoteSummary = { fetch_url: string; name: string; }; + +export type GitRepositorySummary = { branch?: string | null; dirty: boolean; head?: string | null; remotes: Array; status: string; }; + +export type RepositoryApiError = { diagnostics?: Array; error: string; message: string; }; + +export type RepositoryDetailResponse = { item: RepositorySummary; source: string; workspace_id: string; }; + +export type RepositoryDiagnostic = { code: string; message: string; severity: string; }; + +export type RepositoryListResponse = { diagnostics: Array; items: Array; source: string; workspace_id: string; }; + +export type RepositoryObservedStatus = "unverified" | "ready" | "invalid"; + +export type RepositorySource = { kind: RepositorySourceKind; uri: string; }; + +export type RepositorySourceKind = "local_path" | "file" | "ssh" | "https" | "invalid"; + +export type RepositorySummary = { default_selector?: string | null; diagnostics?: Array | null; git?: GitRepositorySummary | null; kind: string; observed_at?: string | null; observed_status: RepositoryObservedStatus; provider: string; record_authority: string; repository_key: string; source: RepositorySource; source_fingerprint: string; source_revision: number; }; diff --git a/web/workspace/src/lib/generated/skill-api.ts b/web/workspace/src/lib/generated/skill-api.ts index b7812b030..3f75a4e86 100644 --- a/web/workspace/src/lib/generated/skill-api.ts +++ b/web/workspace/src/lib/generated/skill-api.ts @@ -1,5 +1,5 @@ -// Generated from workspace-api. Do not edit by hand. -// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_skill_api_types > web/workspace/src/lib/generated/skill-api.ts +// Generated from server-api. Do not edit by hand. +// Regenerate: cargo run -q -p server-api --features typescript --example generate_skill_api_types > web/workspace/src/lib/generated/skill-api.ts export const SKILL_API_AUTHORITY = "workspace-config-skills-v1" as const; @@ -20,69 +20,22 @@ export const SKILL_API_LIMITS = { export type SkillDiagnosticSeverity = "error" | "warning"; -export type SkillDiagnostic = { - severity: SkillDiagnosticSeverity; - code: string; - message: string; - source?: string; -}; +export type SkillDiagnostic = { severity: SkillDiagnosticSeverity, code: string, message: string, source?: string, }; export type SkillSourceKind = "builtin" | "workspace"; -export type SkillProvenance = { - kind: SkillSourceKind; - id: string; - virtual_path?: string; - revision?: number; - source_digest?: string; - tree_digest?: string; -}; +export type SkillProvenance = { kind: SkillSourceKind, id: string, virtual_path?: string, revision?: number, source_digest?: string, tree_digest?: string, }; export type SkillActivationStatus = "active" | "inactive"; export type SkillProjectionStatus = "valid" | "invalid"; -export type SkillProjectionIdentity = { - config_revision: number; - tree_digest: string; -}; +export type SkillProjectionIdentity = { config_revision: number, tree_digest: string, }; -export type SkillResourceRef = { - kind: string; - name: string; - supported: boolean; - diagnostic?: string; -}; +export type SkillResourceRef = { kind: string, name: string, supported: boolean, diagnostic?: string, }; -export type SkillCatalogEntry = { - name: string; - description: string; - activation_status: SkillActivationStatus; - projection_status: SkillProjectionStatus; - provenance: SkillProvenance; - overrides: Array; - diagnostics: Array; -}; +export type SkillCatalogEntry = { name: string, description: string, activation_status: SkillActivationStatus, projection_status: SkillProjectionStatus, provenance: SkillProvenance, overrides: Array, diagnostics: Array, }; -export type SkillCatalogResponse = { - authority: string; - projection: SkillProjectionIdentity; - entries: Array; - diagnostics: Array; -}; +export type SkillCatalogResponse = { authority: string, projection: SkillProjectionIdentity, entries: Array, diagnostics: Array, }; -export type SkillDetailResponse = { - authority: string; - projection: SkillProjectionIdentity; - name: string; - description: string; - provenance: SkillProvenance; - overrides: Array; - diagnostics: Array; - activation_status: SkillActivationStatus; - projection_status: SkillProjectionStatus; - body: string; - allowed_tools: Array; - allowed_tools_status: string; - resources: Array; -}; +export type SkillDetailResponse = { authority: string, projection: SkillProjectionIdentity, name: string, description: string, provenance: SkillProvenance, overrides: Array, diagnostics: Array, activation_status: SkillActivationStatus, projection_status: SkillProjectionStatus, body: string, allowed_tools: Array, allowed_tools_status: string, resources: Array, }; diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index e665e600c..7673d592d 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,77 +1,24 @@ -// Generated from workspace-api. Do not edit by hand. -// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts +// Generated from server-api. Do not edit by hand. +// Regenerate: cargo run -q -p server-api --features typescript --example generate_workdir_api_types > web/workspace/src/lib/generated/workdir-api.ts export type DiagnosticSeverity = "info" | "warning" | "error"; -export type Diagnostic = { - code: string; - severity: DiagnosticSeverity; - message: string; -}; +export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, }; export type WorkingDirectoryMaterializerKind = "runtime_git_clone"; -export type WorkingDirectoryStatusKind = - | "active" - | "cleanup_pending" - | "corrupted" - | "not_found" - | "unknown"; +export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown"; -export type WorkingDirectoryCleanupTarget = { - kind: string; - working_directory_id: string; - repository_key: string; -}; +export type WorkingDirectoryCleanupTarget = { kind: string, working_directory_id: string, repository_key: string, }; -export type WorkingDirectoryOccupancy = { - runtime_id: string; - worker_id: string; - display_name: string; - linked_at: string; -}; +export type WorkingDirectoryOccupancy = { runtime_id: string, worker_id: string, display_name: string, linked_at: string, }; -export type WorkingDirectorySummary = { - working_directory_id: string; - repository_key: string; - creation_selector?: string | null; - creation_ref?: string | null; - creation_tree?: string | null; - current_selector?: string | null; - current_ref?: string | null; - current_tree?: string | null; - observed_at_epoch_seconds?: number | null; - materializer_kind: WorkingDirectoryMaterializerKind; - cleanup_target?: WorkingDirectoryCleanupTarget | null; - status: WorkingDirectoryStatusKind; - cleanliness?: string | null; - primary_worker_id?: string | null; - occupied_by?: WorkingDirectoryOccupancy | null; -}; +export type WorkingDirectorySummary = { working_directory_id: string, repository_key: string, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, primary_worker_id?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, }; -export type WorkingDirectoryCreateRequest = { - runtime_id?: string | null; - repository_key: string; - selector?: string | null; - operation_id?: string | null; -}; +export type WorkingDirectoryCreateRequest = { runtime_id?: string | null, repository_key: string, selector?: string | null, operation_id?: string | null, }; -export type WorkingDirectoryListResponse = { - workspace_id: string; - items: Array; - diagnostics: Array; -}; +export type WorkingDirectoryListResponse = { workspace_id: string, items: Array, diagnostics: Array, }; -export type WorkingDirectoryDetailResponse = { - workspace_id: string; - runtime_id: string; - item: WorkingDirectorySummary; - diagnostics: Array; -}; +export type WorkingDirectoryDetailResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array, }; -export type WorkingDirectoryCreateResponse = { - workspace_id: string; - runtime_id: string; - item: WorkingDirectorySummary; - diagnostics: Array; -}; +export type WorkingDirectoryCreateResponse = { workspace_id: string, runtime_id: string, item: WorkingDirectorySummary, diagnostics: Array, }; diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index ba696b455..7ed93f50d 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,183 +1,52 @@ -// Generated from workspace-api. Do not edit by hand. -// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_worker_launch_api_types > web/workspace/src/lib/generated/worker-launch-api.ts +// Generated from server-api. Do not edit by hand. +// Regenerate: cargo run -q -p server-api --features typescript --example generate_worker_launch_api_types > web/workspace/src/lib/generated/worker-launch-api.ts import type { Segment } from "./protocol"; export type DiagnosticSeverity = "info" | "warning" | "error"; -export type Diagnostic = { - code: string; - severity: DiagnosticSeverity; - message: string; -}; +export type Diagnostic = { code: string, severity: DiagnosticSeverity, message: string, }; export type WorkingDirectoryMaterializerKind = "runtime_git_clone"; -export type WorkingDirectoryStatusKind = - | "active" - | "cleanup_pending" - | "corrupted" - | "not_found" - | "unknown"; +export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown"; -export type WorkingDirectoryCleanupTarget = { - kind: string; - working_directory_id: string; - repository_key: string; -}; +export type WorkingDirectoryCleanupTarget = { kind: string, working_directory_id: string, repository_key: string, }; -export type RuntimeWorkingDirectoryCleanupTarget = { - kind: string; - working_directory_id: string; - repository_id: string; -}; +export type RuntimeWorkingDirectoryCleanupTarget = { kind: string, working_directory_id: string, repository_id: string, }; -export type RuntimeWorkingDirectorySummary = { - working_directory_id: string; - repository_id: string; - creation_selector?: string | null; - creation_ref?: string | null; - creation_tree?: string | null; - current_selector?: string | null; - current_ref?: string | null; - current_tree?: string | null; - observed_at_epoch_seconds?: number | null; - materializer_kind: WorkingDirectoryMaterializerKind; - cleanup_target?: RuntimeWorkingDirectoryCleanupTarget | null; - status: WorkingDirectoryStatusKind; - cleanliness?: string | null; - primary_worker_id?: string | null; - occupied_by?: WorkingDirectoryOccupancy | null; -}; +export type RuntimeWorkingDirectorySummary = { working_directory_id: string, repository_id: string, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: RuntimeWorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, primary_worker_id?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, }; -export type WorkingDirectoryOccupancy = { - runtime_id: string; - worker_id: string; - display_name: string; - linked_at: string; -}; +export type WorkingDirectoryOccupancy = { runtime_id: string, worker_id: string, display_name: string, linked_at: string, }; -export type WorkingDirectorySummary = { - working_directory_id: string; - repository_key: string; - creation_selector?: string | null; - creation_ref?: string | null; - creation_tree?: string | null; - current_selector?: string | null; - current_ref?: string | null; - current_tree?: string | null; - observed_at_epoch_seconds?: number | null; - materializer_kind: WorkingDirectoryMaterializerKind; - cleanup_target?: WorkingDirectoryCleanupTarget | null; - status: WorkingDirectoryStatusKind; - cleanliness?: string | null; - primary_worker_id?: string | null; - occupied_by?: WorkingDirectoryOccupancy | null; -}; +export type WorkingDirectorySummary = { working_directory_id: string, repository_key: string, creation_selector?: string | null, creation_ref?: string | null, creation_tree?: string | null, current_selector?: string | null, current_ref?: string | null, current_tree?: string | null, observed_at_epoch_seconds?: number | null, materializer_kind: WorkingDirectoryMaterializerKind, cleanup_target?: WorkingDirectoryCleanupTarget | null, status: WorkingDirectoryStatusKind, cleanliness?: string | null, primary_worker_id?: string | null, occupied_by?: WorkingDirectoryOccupancy | null, }; -export type WorkerWorkspaceSummary = { - visibility: string; - identity: string; - workspace_id?: string | null; -}; +export type WorkerWorkspaceSummary = { visibility: string, identity: string, workspace_id?: string | null, }; -export type WorkerImplementationSummary = { - kind: string; - display_hint: string; -}; +export type WorkerImplementationSummary = { kind: string, display_hint: string, }; -export type WorkerCapabilitySummary = { - can_stop: boolean; - can_spawn_followup: boolean; -}; +export type WorkerCapabilitySummary = { can_stop: boolean, can_spawn_followup: boolean, }; -export type WorkerLaunchWorkerSummary = { - runtime_id: string; - worker_id: string; - host_id: string; - display_name: string; - label: string; - profile: string | null; - singleton_key: string | null; - tags: Array; - workspace: WorkerWorkspaceSummary; - state: string; - last_seen_at: string | null; - pinned: boolean; - retention_state: string; - implementation: WorkerImplementationSummary; - capabilities: WorkerCapabilitySummary; - working_directory?: RuntimeWorkingDirectorySummary | null; - diagnostics: Array; -}; +export type WorkerLaunchWorkerSummary = { runtime_id: string, worker_id: string, host_id: string, display_name: string, label: string, profile: string | null, singleton_key: string | null, tags: Array, workspace: WorkerWorkspaceSummary, state: string, last_seen_at: string | null, pinned: boolean, retention_state: string, implementation: WorkerImplementationSummary, capabilities: WorkerCapabilitySummary, working_directory?: RuntimeWorkingDirectorySummary | null, diagnostics: Array, }; -export type WorkerLaunchRuntimeOption = { - runtime_id: string; - display_name: string; - built_in: boolean; - worker_creation_available: boolean; - working_directory_required: boolean; - status: string; - diagnostics: Array; -}; +export type WorkerLaunchRuntimeOption = { runtime_id: string, display_name: string, built_in: boolean, worker_creation_available: boolean, working_directory_required: boolean, status: string, diagnostics: Array, }; -export type WorkerLaunchProfileCandidate = { - id: string; - label: string; - description: string; -}; +export type WorkerLaunchProfileCandidate = { id: string, label: string, description: string, }; -export type WorkingDirectoryRepositoryOption = { - repository_key: string; - default_selector?: string | null; -}; +export type WorkingDirectoryRepositoryOption = { repository_key: string, default_selector?: string | null, }; -export type WorkerLaunchOptionsResponse = { - workspace_id: string; - runtimes: Array; - default_profile: string | null; - profiles: Array; - repositories: Array; - working_directories: Array; - diagnostics: Array; -}; +export type WorkerLaunchOptionsResponse = { workspace_id: string, runtimes: Array, default_profile: string | null, profiles: Array, repositories: Array, working_directories: Array, diagnostics: Array, }; -export type BrowserWorkerWorkingDirectorySelection = { - working_directory_id: string; - relative_cwd: string | null; -}; +export type BrowserWorkerWorkingDirectorySelection = { working_directory_id: string, relative_cwd: string | null, }; -export type CreateWorkspaceWorkerTicketAssignmentRequest = { - ticket_id: string; - operation_id: string; -}; +export type CreateWorkspaceWorkerTicketAssignmentRequest = { ticket_id: string, operation_id: string, }; -export type CreateWorkspaceWorkerRequest = { - runtime_id: string; - display_name: string; - profile: string | null; - ticket_assignment: CreateWorkspaceWorkerTicketAssignmentRequest | null; - initial_submit: Array; - working_directory: BrowserWorkerWorkingDirectorySelection | null; - /** - * Backend idempotency key used only for authenticated Worker-owned spawn/control. - */ - control_operation_id: string | null; -}; +export type CreateWorkspaceWorkerRequest = { runtime_id: string, display_name: string, profile: string | null, ticket_assignment: CreateWorkspaceWorkerTicketAssignmentRequest | null, initial_submit: Array, working_directory: BrowserWorkerWorkingDirectorySelection | null, +/** + * Backend idempotency key used only for authenticated Worker-owned spawn/control. + */ +control_operation_id: string | null, }; -export type BrowserCreateWorkerResponse = { - workspace_id: string; - runtime_id: string; - worker_id: string; - console_href: string; - worker: WorkerLaunchWorkerSummary; - diagnostics: Array; -}; +export type BrowserCreateWorkerResponse = { workspace_id: string, runtime_id: string, worker_id: string, console_href: string, worker: WorkerLaunchWorkerSummary, diagnostics: Array, }; -export type BrowserWorkspaceOrchestratorResponse = { - workspace_id: string; - online: boolean; - disposition: string; - worker?: WorkerLaunchWorkerSummary | null; - diagnostics: Array; -}; +export type BrowserWorkspaceOrchestratorResponse = { workspace_id: string, online: boolean, disposition: string, worker?: WorkerLaunchWorkerSummary | null, diagnostics: Array, }; diff --git a/web/workspace/src/lib/generated/workspace-api.ts b/web/workspace/src/lib/generated/workspace-api.ts deleted file mode 100644 index 7c3c42db1..000000000 --- a/web/workspace/src/lib/generated/workspace-api.ts +++ /dev/null @@ -1,552 +0,0 @@ -// This file is generated by `cargo run -p workspace-api --features typescript --example generate_typescript | deno fmt -`. -// Do not edit this file directly. - -export type WorkspaceSummary = { - workspace_id: string; - owner_account_id: string; - display_name: string; - state: string; - created_at: string; - updated_at: string; -}; - -export type WorkspaceCatalogListResponse = Array; - -export type WorkspaceRepositoryRecord = { - workspace_id: string; - repository_key: string; - kind: string; - provider: string | null; - source: RepositorySource; - default_ref: string | null; - source_revision: number; - source_fingerprint: string; - observed_status: RepositoryObservedStatus; - observed_at: string | null; - created_at: string; - updated_at: string; -}; - -export type WorkspaceCreateResponse = { - workspace: WorkspaceSummary; - repository: WorkspaceRepositoryRecord; - config_revision: number; - request_fingerprint: string; - replayed: boolean; -}; - -export type WorkspaceAuthConfig = { - "Passkey": { - rp_id: string; - origin: string; - public_base_url: string; - cookie_name: string; - }; -}; - -export type WorkspacePermissionSummary = { - manage_repositories: boolean; - manage_secrets: boolean; - manage_runtimes: boolean; - delete_workspace: boolean; -}; - -export type WorkspaceDeletionState = - | "queued" - | "running" - | "blocked" - | "failed" - | "succeeded"; - -export type WorkspaceDeletionBlockerKind = - | "last_accessible_workspace" - | "revision_conflict" - | "dirty_workdir" - | "worker_removal_blocked" - | "workdir_removal_blocked" - | "retention_hold" - | "cleanup_unavailable"; - -export type WorkspaceDeletionBlocker = { - kind: WorkspaceDeletionBlockerKind; - resource_kind: string | null; - resource_key: string | null; - message: string; -}; - -export type WorkspaceDeletionResourceCounts = { - workers: number; - workdirs: number; - repositories: number; - runtime_bindings: number; - secrets: number; - artifacts: number; -}; - -export type WorkspaceDeletionPreflightResponse = { - workspace_id: string; - display_name: string; - /** - * Opaque persisted Workspace metadata revision used as a CAS fence. - */ - expected_revision: string; - can_delete: boolean; - resources: WorkspaceDeletionResourceCounts; - blockers: Array; -}; - -export type WorkspaceDeletionRequest = { - operation_id: string; - expected_revision: string; - confirmation: string; -}; - -export type WorkspaceDeletionOperationResponse = { - operation_id: string; - workspace_id: string; - display_name: string; - state: WorkspaceDeletionState; - resources: WorkspaceDeletionResourceCounts; - child_operation_ids: Array; - blockers: Array; - failure_category: string | null; - created_at: string; - updated_at: string; - completed_at: string | null; -}; - -export type DiagnosticSeverity = "info" | "warning" | "error"; - -export type Diagnostic = { - code: string; - severity: DiagnosticSeverity; - message: string; -}; - -export type WorkspaceExtensionPointState = { - status: string; - note: string; - diagnostics: Array; -}; - -export type WorkspaceExtensionPoints = { - store: string; - event_stream: WorkspaceExtensionPointState; - host_worker_bridge: WorkspaceExtensionPointState; - companion_console: WorkspaceExtensionPointState; -}; - -export type WorkspaceResponse = { - workspace_id: string; - display_name: string; - record_authority: string; - schema_version: number; - auth: WorkspaceAuthConfig; - permissions: WorkspacePermissionSummary; - extension_points: WorkspaceExtensionPoints; -}; - -export type WorkspaceMetadataSettingsResponse = { - workspace_id: string; - display_name: string; - created_at: string; - revision: string; - source: string; - diagnostics: Array; -}; - -export type UpdateWorkspaceMetadataRequest = { - display_name: string; - revision: string; -}; - -export type WorkspaceMetadataMutationResponse = { - workspace: WorkspaceMetadataSettingsResponse; - diagnostics: Array; -}; - -export type WorkspaceSigningIdentityState = "pending_provisioning" | "active"; - -export type WorkspaceSigningIdentityPublic = { - workspace_id: string; - key_id: string; - algorithm: string; - public_key?: string; - public_key_fingerprint?: string; - revision: number; - state: WorkspaceSigningIdentityState; - created_at: string; - provisioned_at?: string; -}; - -export type WorkspacePublicIdentityBundle = { - workspace_id: string; - backend_url: string; - key_id: string; - algorithm: string; - public_key: string; - public_key_fingerprint: string; - revision: number; -}; - -export type WorkspaceSigningIdentityResponse = { - identity: WorkspaceSigningIdentityPublic; - public_bundle?: WorkspacePublicIdentityBundle; -}; - -export type ProfileSettingsResponse = { - workspace_id: string; - registry_revision: string; - config_revision?: number | null; - tree_digest?: string | null; - projection_digest?: string | null; - default_profile?: string | null; - profiles: Array; - sources: Array; - diagnostics: Array; -}; - -export type WorkspaceProfileSummary = { - profile_id: string; - selector: string; - label: string; - source_kind: string; - profile_source_id?: string | null; - description?: string | null; - editable: boolean; - is_default: boolean; - diagnostics: Array; -}; - -export type WorkspaceProfileSourceSummary = { - profile_source_id: string; - display_path: string; - kind: string; - content_type: string; - content_digest: string; - provenance: WorkspaceProfileSourceProvenance; - editable: boolean; - revision: string; - size_bytes: number; - diagnostics: Array; -}; - -export type WorkspaceProfileSourceProvenance = "project_profile_source_tree"; - -export type RepositorySourceKind = - | "local_path" - | "file" - | "ssh" - | "https" - | "invalid"; - -export type RepositorySource = { - kind: RepositorySourceKind; - /** - * Canonical source representation. This is an absolute local path for - * `local_path`, and a normalized URI/remote specification otherwise. - */ - uri: string; -}; - -export type RepositoryObservedStatus = "unverified" | "ready" | "invalid"; - -export type RepositoryDiagnostic = { - severity: string; - code: string; - message: string; -}; - -export type GitRemoteSummary = { name: string; fetch_url: string }; - -export type GitRepositorySummary = { - status: string; - head: string | null; - branch: string | null; - dirty: boolean; - remotes: Array; -}; - -export type RepositorySummary = { - repository_key: string; - kind: string; - provider: string; - source: RepositorySource; - source_revision: number; - source_fingerprint: string; - observed_status: RepositoryObservedStatus; - observed_at?: string | null; - default_selector?: string | null; - record_authority: string; - git?: GitRepositorySummary | null; - diagnostics?: Array | null; -}; - -export type GitCommitSummary = { - hash: string; - short_hash: string; - summary: string; - author_name: string; - author_email: string; - author_date: string; - parents: Array; - refs: Array; -}; - -export type RepositoryListResponse = { - workspace_id: string; - items: Array; - source: string; - diagnostics: Array; -}; - -export type RepositoryDetailResponse = { - workspace_id: string; - item: RepositorySummary; - source: string; -}; - -export type RepositorySshConnectionProbeRequest = { runtime_id: string }; - -export type RepositorySshHostKeyCandidate = { - algorithm: string; - host_key: string; - fingerprint: string; -}; - -export type RepositorySshConnectionTrustState = - | "untrusted" - | "verified" - | "changed"; - -export type RepositorySshConnectionProbeResponse = { - workspace_id: string; - repository_key: string; - runtime_id: string; - hostname: string; - port: number; - trust_state: RepositorySshConnectionTrustState; - host_trust_id: string; - expected_host_trust_revision: number | null; - candidates: Array; -}; - -export type ConfirmRepositorySshHostTrustRequest = { - operation_id: string; - runtime_id: string; - host_key: string; - expected_host_trust_revision: number | null; -}; - -export type RepositoryLogResponse = { - workspace_id: string; - repository_key: string; - default_selector?: string | null; - limit: number; - items: Array; - diagnostics: Array; -}; - -export type RuntimeSourceKind = "embedded_worker_runtime" | "remote_http"; - -export type RuntimeSourceStatus = "active" | "reserved"; - -export type RuntimeIdentityAuthority = - | "runtime_registry_projection" - | "server_runtime_configuration"; - -export type RuntimeSourceSummary = { - kind: RuntimeSourceKind; - status: RuntimeSourceStatus; - identity_authority: RuntimeIdentityAuthority; - note: string; -}; - -export type RuntimeSummary = { - runtime_id: string; - label: string; - kind: string; - status: string; - source: RuntimeSourceSummary; - host_ids: Array; - worker_creation_available: boolean; - os: string; - arch: string; - diagnostics: Array; -}; - -export type WorkspaceRuntimeBindingState = - | "configured" - | "verified" - | "revoked"; - -export type RuntimeConnectionDisplayState = - | "configured" - | "verified" - | "unavailable" - | "revoked"; - -export type RuntimeVerificationOutcome = - | "verified" - | "challenge_issued" - | "verification_failed" - | "connectivity_failed"; - -export type RuntimeVerificationEvidenceSummary = { - verified_at: string | null; - last_checked_at: string; - last_outcome: RuntimeVerificationOutcome; - binding_revision: number; - workspace_key_id: string; - workspace_identity_revision: number; - workspace_trust_generation: number; - runtime_public_key_fingerprint: string; - runtime_identity_revision: number; -}; - -export type WorkspaceRuntimeBindingSummary = { - state: WorkspaceRuntimeBindingState; - connection_state: RuntimeConnectionDisplayState; - revision: number; - workspace_key_id?: string | null; - workspace_key_generation?: number | null; - verification?: RuntimeVerificationEvidenceSummary | null; -}; - -export type RuntimeManagementSummary = { - built_in: boolean; - config_managed: boolean; - removable: boolean; - endpoint_configured: boolean; - token_ref_configured: boolean; - binding?: WorkspaceRuntimeBindingSummary | null; -}; - -export type WorkspaceRuntimeResource = { - management: RuntimeManagementSummary; - runtime_id: string; - label: string; - kind: string; - status: string; - source: RuntimeSourceSummary; - host_ids: Array; - worker_creation_available: boolean; - os: string; - arch: string; - diagnostics: Array; -}; - -export type RuntimeTrustKeyStatus = "unconfigured" | "active" | "revoked"; - -export type RuntimeTrustKeyState = { - status: RuntimeTrustKeyStatus; - fingerprint?: string | null; - revision?: number | null; - created_at?: string | null; - updated_at?: string | null; - revoked_at?: string | null; -}; - -export type RuntimeTrustAuditAction = - | "created" - | "replaced" - | "reactivated" - | "revoked"; - -export type RuntimeTrustAuditEntry = { - action: RuntimeTrustAuditAction; - actor_account_id: string; - old_fingerprint?: string | null; - new_fingerprint?: string | null; - revision: number; - at: string; -}; - -export type WorkspaceRuntimeDetail = { - workspace_id: string; - runtime: WorkspaceRuntimeResource; - endpoint?: string | null; - trust_key: RuntimeTrustKeyState; - recent_audit: Array; -}; - -export type RuntimeTrustKeyRevealResponse = { public_key: string }; - -export type RevokeRuntimeTrustKeyRequest = { expected_revision: number }; - -export type RemoveRuntimeRequest = { - operation_id: string; - expected_binding_revision: number; -}; - -export type RuntimeRemovalOperationState = - | "pending" - | "cleanup_pending" - | "succeeded" - | "failed"; - -export type RuntimeRemovalOperationResponse = { - operation_id: string; - workspace_id: string; - runtime_id: string; - state: RuntimeRemovalOperationState; - binding_removed: boolean; - runtime_registration_removed: boolean | null; - failure_category?: string | null; - created_at: string; - updated_at: string; - completed_at?: string | null; -}; - -export type RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use"; - -export type RuntimeTrustConflictResponse = { - error: RuntimeTrustConflictKind; - message: string; - current_revision?: number; - current_fingerprint?: string | null; -}; - -export type RuntimePublicIdentityBundle = { - identity_id: string; - public_key: string; -}; - -export type CreateRemoteRuntimeRequest = { - public_bundle: RuntimePublicIdentityBundle; - display_name?: string | null; - endpoint: string; - expected_revision?: number | null; -}; - -export type UpdateRemoteRuntimeRequest = { - display_name?: string | null; - endpoint: string; -}; - -export type RuntimeConnectionTestStatus = "compatible" | "failed"; - -export type RuntimeConnectionTestFailureKind = - | "authentication" - | "authorization" - | "network_unreachable" - | "timeout" - | "tls_or_transport" - | "malformed_response" - | "protocol_version_mismatch" - | "runtime_identity_mismatch" - | "configuration"; - -export type RuntimeConnectionTestResponse = { - workspace_id: string; - runtime_id: string; - binding_revision: number; - connection_state: RuntimeConnectionDisplayState; - verification: RuntimeVerificationEvidenceSummary | null; - checked_at: string; - status: RuntimeConnectionTestStatus; - failure_kind: RuntimeConnectionTestFailureKind | null; - expected_protocol_version: number; - actual_protocol_version: number | null; - diagnostics: Array; -}; diff --git a/web/workspace/src/lib/workspace/api/http.ts b/web/workspace/src/lib/workspace/api/http.ts index 2f207fac1..15e240d34 100644 --- a/web/workspace/src/lib/workspace/api/http.ts +++ b/web/workspace/src/lib/workspace/api/http.ts @@ -16,7 +16,7 @@ export type ApiResult = { export type { SkillCatalogResponse, SkillDetailResponse }; -type JsonLoadPolicy = { +export type JsonLoadPolicy = { diagnosticLabel: string; maxResponseBytes: number; }; @@ -133,7 +133,7 @@ export async function loadJson( } } -async function readBoundedJson( +export async function readBoundedJson( response: Response, maxBytes: number, ): Promise { diff --git a/web/workspace/src/lib/workspace/api/repositories.ts b/web/workspace/src/lib/workspace/api/repositories.ts new file mode 100644 index 000000000..3a505dce8 --- /dev/null +++ b/web/workspace/src/lib/workspace/api/repositories.ts @@ -0,0 +1,102 @@ +import type { + CreateWorkspaceRepositoryRequest, + CreateWorkspaceRepositoryResponse, + RepositoryDetailResponse, + RepositoryListResponse, +} from "$lib/generated/repository-api.ts"; +import { + type ApiResult, + loadJson, + readBoundedJson, + workspaceApiPath, +} from "$lib/workspace/api/http.ts"; +import { + parseCreateWorkspaceRepositoryResponse, + parseRepositoryApiError, + parseRepositoryDetailResponse, + parseRepositoryListResponse, +} from "$lib/workspace/api/workspace-model.ts"; + +export const REPOSITORY_API_MAX_RESPONSE_BYTES = 2 * 1024 * 1024; + +const REPOSITORY_API_LOAD_POLICY = { + diagnosticLabel: "Repository API", + maxResponseBytes: REPOSITORY_API_MAX_RESPONSE_BYTES, +} as const; + +export function workspaceRepositoryListPath(workspaceId: string): string { + return workspaceApiPath(workspaceId, "/repositories"); +} + +export function workspaceRepositoryDetailPath( + workspaceId: string, + repositoryKey: string, +): string { + return workspaceApiPath( + workspaceId, + `/repositories/${encodeURIComponent(repositoryKey)}`, + ); +} + +export async function loadWorkspaceRepositoryList( + fetchFn: typeof fetch, + workspaceId: string, +): Promise> { + return loadJson( + fetchFn, + workspaceRepositoryListPath(workspaceId), + undefined, + parseRepositoryListResponse, + REPOSITORY_API_LOAD_POLICY, + ); +} + +export async function loadWorkspaceRepositoryDetail( + fetchFn: typeof fetch, + workspaceId: string, + repositoryKey: string, +): Promise> { + return loadJson( + fetchFn, + workspaceRepositoryDetailPath(workspaceId, repositoryKey), + undefined, + parseRepositoryDetailResponse, + REPOSITORY_API_LOAD_POLICY, + ); +} + +export async function createWorkspaceRepository( + fetchFn: typeof fetch, + workspaceId: string, + request: CreateWorkspaceRepositoryRequest, +): Promise> { + const path = workspaceRepositoryListPath(workspaceId); + try { + const response = await fetchFn(path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }); + const payload = await readBoundedJson( + response, + REPOSITORY_API_MAX_RESPONSE_BYTES, + ); + if (!response.ok) { + try { + const error = parseRepositoryApiError(payload); + return { data: null, error: error.message }; + } catch { + return { + data: null, + error: `Repository API request failed with HTTP ${response.status}`, + }; + } + } + return { + data: parseCreateWorkspaceRepositoryResponse(payload), + error: null, + }; + } catch { + return { data: null, error: "Repository API response is invalid" }; + } +} diff --git a/web/workspace/src/lib/workspace/api/runtime-connection.ts b/web/workspace/src/lib/workspace/api/runtime-connection.ts index 05ec74ce5..ada1ff6aa 100644 --- a/web/workspace/src/lib/workspace/api/runtime-connection.ts +++ b/web/workspace/src/lib/workspace/api/runtime-connection.ts @@ -3,7 +3,7 @@ import type { RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse, RuntimeVerificationEvidenceSummary, -} from "$lib/generated/workspace-api"; +} from "$lib/generated/legacy-server-api"; const RESPONSE_KEYS = [ "workspace_id", diff --git a/web/workspace/src/lib/workspace/api/runtime-management.ts b/web/workspace/src/lib/workspace/api/runtime-management.ts index d7e363cfe..92cd1ba0a 100644 --- a/web/workspace/src/lib/workspace/api/runtime-management.ts +++ b/web/workspace/src/lib/workspace/api/runtime-management.ts @@ -24,7 +24,7 @@ import type { WorkspaceRuntimeBindingSummary, WorkspaceRuntimeDetail, WorkspaceRuntimeResource, -} from "$lib/generated/workspace-api.ts"; +} from "$lib/generated/legacy-server-api.ts"; import type { ListResponse } from "$lib/workspace/sidebar/types"; import { workspaceApiPath } from "./http.ts"; diff --git a/web/workspace/src/lib/workspace/api/workspace-catalog.ts b/web/workspace/src/lib/workspace/api/workspace-catalog.ts index 320195eb1..4287c9a31 100644 --- a/web/workspace/src/lib/workspace/api/workspace-catalog.ts +++ b/web/workspace/src/lib/workspace/api/workspace-catalog.ts @@ -1,11 +1,11 @@ import { - parseRepositoryListResponse, parseWorkspaceCatalogResponse, parseWorkspaceCreateResponse, type RepositorySummary, type WorkspaceCreateResponse, type WorkspaceSummary, } from "$lib/workspace/api/workspace-model"; +import { loadWorkspaceRepositoryList } from "$lib/workspace/api/repositories.ts"; export type WorkspaceCatalogRecord = WorkspaceSummary; export type WorkspaceCatalogItem = WorkspaceCatalogRecord & { @@ -48,12 +48,14 @@ export async function listWorkspaceRepositories( fetcher: Fetch, workspaceId: string, ): Promise { - return parseRepositoryListResponse( - await fetchJson( - fetcher, - `/api/w/${encodeURIComponent(workspaceId)}/repositories`, - ), - ).items; + const result = await loadWorkspaceRepositoryList(fetcher, workspaceId); + if (result.data === null) { + throw new WorkspaceCatalogError( + null, + result.error ?? "Repository API request failed", + ); + } + return result.data.items; } export async function loadWorkspaceCatalog( diff --git a/web/workspace/src/lib/workspace/api/workspace-model.ts b/web/workspace/src/lib/workspace/api/workspace-model.ts index 38d5b0281..0665f423b 100644 --- a/web/workspace/src/lib/workspace/api/workspace-model.ts +++ b/web/workspace/src/lib/workspace/api/workspace-model.ts @@ -1,19 +1,10 @@ import type { ApiResult } from "$lib/workspace/api/http"; import type { - Diagnostic, GitCommitSummary, - GitRemoteSummary, - GitRepositorySummary, - RepositoryDetailResponse, - RepositoryDiagnostic, - RepositoryListResponse, RepositoryLogResponse, - RepositorySource, - RepositorySourceKind, RepositorySshConnectionProbeResponse, RepositorySshConnectionTrustState, RepositorySshHostKeyCandidate, - RepositorySummary, WorkspaceAuthConfig, WorkspaceCatalogListResponse, WorkspaceCreateResponse, @@ -29,18 +20,26 @@ import type { WorkspaceRepositoryRecord, WorkspaceResponse, WorkspaceSummary, -} from "$lib/generated/workspace-api.ts"; +} from "$lib/generated/legacy-server-api.ts"; +import type { + CreateWorkspaceRepositoryResponse, + Diagnostic, + GitRemoteSummary, + GitRepositorySummary, + RepositoryApiError, + RepositoryDetailResponse, + RepositoryDiagnostic, + RepositoryListResponse, + RepositorySource, + RepositorySourceKind, + RepositorySummary, +} from "$lib/generated/repository-api.ts"; export type { GitCommitSummary, - GitRemoteSummary, - GitRepositorySummary, - RepositoryDetailResponse, - RepositoryListResponse, RepositoryLogResponse, RepositorySshConnectionProbeResponse, RepositorySshHostKeyCandidate, - RepositorySummary, WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceDeletionOperationResponse, @@ -48,7 +47,19 @@ export type { WorkspacePermissionSummary, WorkspaceResponse, WorkspaceSummary, -} from "$lib/generated/workspace-api.ts"; +} from "$lib/generated/legacy-server-api.ts"; +export type { + CreateWorkspaceRepositoryRequest, + CreateWorkspaceRepositoryResponse, + GitRemoteSummary, + GitRepositorySummary, + RepositoryApiError, + RepositoryDetailResponse, + RepositoryListResponse, + RepositorySource, + RepositorySourceKind, + RepositorySummary, +} from "$lib/generated/repository-api.ts"; type JsonObject = Record; @@ -62,6 +73,11 @@ const SOURCE_KINDS = new Set([ const OBSERVED_STATUSES = new Set(["unverified", "ready", "invalid"]); const DIAGNOSTIC_SEVERITIES = new Set(["info", "warning", "error"]); +export const REPOSITORY_API_LIMITS = { + maxStringCodeUnits: 65_536, + maxCollectionEntries: 4_096, +} as const; + function object(value: unknown, path: string): JsonObject { if (typeof value !== "object" || value === null || Array.isArray(value)) { throw new Error(`${path} must be an object`); @@ -95,6 +111,34 @@ function nullableString(value: unknown, path: string): string | null { return value === null ? null : string(value, path); } +function repositoryString(value: unknown, path: string): string { + const parsed = string(value, path); + if (parsed.length > REPOSITORY_API_LIMITS.maxStringCodeUnits) { + throw new Error(`${path} exceeds the Repository API string limit`); + } + return parsed; +} + +function nullableRepositoryString(value: unknown, path: string): string | null { + return value === null ? null : repositoryString(value, path); +} + +function repositoryArray(value: unknown, path: string): unknown[] { + const parsed = array(value, path); + if (parsed.length > REPOSITORY_API_LIMITS.maxCollectionEntries) { + throw new Error(`${path} exceeds the Repository API collection limit`); + } + return parsed; +} + +function repositorySourceRevision(value: unknown, path: string): number { + const parsed = integer(value, path); + if (parsed < 0) { + throw new Error(`${path} must be between 0 and Number.MAX_SAFE_INTEGER`); + } + return parsed; +} + function optionalNullableString( value: unknown, path: string, @@ -102,6 +146,15 @@ function optionalNullableString( return value === undefined ? undefined : nullableString(value, path); } +function optionalNullableRepositoryString( + value: unknown, + path: string, +): string | null | undefined { + return value === undefined + ? undefined + : nullableRepositoryString(value, path); +} + function exactKeys( value: JsonObject, keys: readonly string[], @@ -128,6 +181,20 @@ function diagnostic(value: unknown, path: string): Diagnostic { }; } +function repositoryApiDiagnostic(value: unknown, path: string): Diagnostic { + const item = object(value, path); + exactKeys(item, ["code", "severity", "message"], path); + const severity = repositoryString(item.severity, `${path}.severity`); + if (!DIAGNOSTIC_SEVERITIES.has(severity)) { + throw new Error(`${path}.severity is invalid`); + } + return { + code: repositoryString(item.code, `${path}.code`), + severity: severity as Diagnostic["severity"], + message: repositoryString(item.message, `${path}.message`), + }; +} + function repositoryDiagnostic( value: unknown, path: string, @@ -135,22 +202,22 @@ function repositoryDiagnostic( const item = object(value, path); exactKeys(item, ["severity", "code", "message"], path); return { - severity: string(item.severity, `${path}.severity`), - code: string(item.code, `${path}.code`), - message: string(item.message, `${path}.message`), + severity: repositoryString(item.severity, `${path}.severity`), + code: repositoryString(item.code, `${path}.code`), + message: repositoryString(item.message, `${path}.message`), }; } function repositorySource(value: unknown, path: string): RepositorySource { const source = object(value, path); exactKeys(source, ["kind", "uri"], path); - const kind = string(source.kind, `${path}.kind`); + const kind = repositoryString(source.kind, `${path}.kind`); if (!SOURCE_KINDS.has(kind as RepositorySourceKind)) { throw new Error(`${path}.kind is invalid`); } return { kind: kind as RepositorySourceKind, - uri: string(source.uri, `${path}.uri`), + uri: repositoryString(source.uri, `${path}.uri`), }; } @@ -158,8 +225,8 @@ function gitRemote(value: unknown, path: string): GitRemoteSummary { const remote = object(value, path); exactKeys(remote, ["name", "fetch_url"], path); return { - name: string(remote.name, `${path}.name`), - fetch_url: string(remote.fetch_url, `${path}.fetch_url`), + name: repositoryString(remote.name, `${path}.name`), + fetch_url: repositoryString(remote.fetch_url, `${path}.fetch_url`), }; } @@ -167,13 +234,14 @@ function gitSummary(value: unknown, path: string): GitRepositorySummary { const git = object(value, path); exactKeys(git, ["status", "head", "branch", "dirty", "remotes"], path); return { - status: string(git.status, `${path}.status`), - head: nullableString(git.head, `${path}.head`), - branch: nullableString(git.branch, `${path}.branch`), + status: repositoryString(git.status, `${path}.status`), + head: optionalNullableRepositoryString(git.head, `${path}.head`), + branch: optionalNullableRepositoryString(git.branch, `${path}.branch`), dirty: boolean(git.dirty, `${path}.dirty`), - remotes: array(git.remotes, `${path}.remotes`).map((item, index) => - gitRemote(item, `${path}.remotes[${index}]`) - ), + remotes: repositoryArray(git.remotes, `${path}.remotes`).map(( + item, + index, + ) => gitRemote(item, `${path}.remotes[${index}]`)), }; } @@ -197,7 +265,7 @@ function repositorySummary(value: unknown, path: string): RepositorySummary { ], path, ); - const observedStatus = string( + const observedStatus = repositoryString( item.observed_status, `${path}.observed_status`, ); @@ -207,29 +275,39 @@ function repositorySummary(value: unknown, path: string): RepositorySummary { const diagnostics = item.diagnostics === undefined || item.diagnostics === null ? item.diagnostics - : array(item.diagnostics, `${path}.diagnostics`).map((entry, index) => - repositoryDiagnostic(entry, `${path}.diagnostics[${index}]`) - ); + : repositoryArray(item.diagnostics, `${path}.diagnostics`).map(( + entry, + index, + ) => repositoryDiagnostic(entry, `${path}.diagnostics[${index}]`)); return { - repository_key: string(item.repository_key, `${path}.repository_key`), - kind: string(item.kind, `${path}.kind`), - provider: string(item.provider, `${path}.provider`), + repository_key: repositoryString( + item.repository_key, + `${path}.repository_key`, + ), + kind: repositoryString(item.kind, `${path}.kind`), + provider: repositoryString(item.provider, `${path}.provider`), source: repositorySource(item.source, `${path}.source`), - source_revision: integer(item.source_revision, `${path}.source_revision`), - source_fingerprint: string( + source_revision: repositorySourceRevision( + item.source_revision, + `${path}.source_revision`, + ), + source_fingerprint: repositoryString( item.source_fingerprint, `${path}.source_fingerprint`, ), observed_status: observedStatus as RepositorySummary["observed_status"], - observed_at: optionalNullableString( + observed_at: optionalNullableRepositoryString( item.observed_at, `${path}.observed_at`, ), - default_selector: optionalNullableString( + default_selector: optionalNullableRepositoryString( item.default_selector, `${path}.default_selector`, ), - record_authority: string(item.record_authority, `${path}.record_authority`), + record_authority: repositoryString( + item.record_authority, + `${path}.record_authority`, + ), git: item.git === undefined || item.git === null ? item.git : gitSummary(item.git, `${path}.git`), @@ -287,7 +365,7 @@ function workspaceRepositoryRecord( ], path, ); - const observedStatus = string( + const observedStatus = repositoryString( item.observed_status, `${path}.observed_status`, ); @@ -301,8 +379,11 @@ function workspaceRepositoryRecord( provider: nullableString(item.provider, `${path}.provider`), source: repositorySource(item.source, `${path}.source`), default_ref: nullableString(item.default_ref, `${path}.default_ref`), - source_revision: integer(item.source_revision, `${path}.source_revision`), - source_fingerprint: string( + source_revision: repositorySourceRevision( + item.source_revision, + `${path}.source_revision`, + ), + source_fingerprint: repositoryString( item.source_fingerprint, `${path}.source_fingerprint`, ), @@ -533,21 +614,28 @@ export function parseRepositoryListResponse( "repository list response", ); return { - workspace_id: string( + workspace_id: repositoryString( response.workspace_id, "repository list response.workspace_id", ), - items: array(response.items, "repository list response.items").map(( - item, - index, - ) => repositorySummary(item, `repository list response.items[${index}]`)), - source: string(response.source, "repository list response.source"), - diagnostics: array( + items: repositoryArray(response.items, "repository list response.items") + .map(( + item, + index, + ) => repositorySummary(item, `repository list response.items[${index}]`)), + source: repositoryString( + response.source, + "repository list response.source", + ), + diagnostics: repositoryArray( response.diagnostics, "repository list response.diagnostics", ).map( (item, index) => - diagnostic(item, `repository list response.diagnostics[${index}]`), + repositoryApiDiagnostic( + item, + `repository list response.diagnostics[${index}]`, + ), ), }; } @@ -578,12 +666,64 @@ export function parseRepositoryDetailResponse( "repository detail response", ); return { - workspace_id: string( + workspace_id: repositoryString( response.workspace_id, "repository detail response.workspace_id", ), item: repositorySummary(response.item, "repository detail response.item"), - source: string(response.source, "repository detail response.source"), + source: repositoryString( + response.source, + "repository detail response.source", + ), + }; +} + +export function parseCreateWorkspaceRepositoryResponse( + value: unknown, +): CreateWorkspaceRepositoryResponse { + const response = object(value, "repository create response"); + exactKeys( + response, + ["replayed", "repository_key", "workspace_id"], + "repository create response", + ); + return { + replayed: boolean(response.replayed, "repository create response.replayed"), + repository_key: repositoryString( + response.repository_key, + "repository create response.repository_key", + ), + workspace_id: repositoryString( + response.workspace_id, + "repository create response.workspace_id", + ), + }; +} + +export function parseRepositoryApiError(value: unknown): RepositoryApiError { + const response = object(value, "repository error response"); + exactKeys( + response, + ["diagnostics", "error", "message"], + "repository error response", + ); + return { + diagnostics: response.diagnostics === undefined + ? undefined + : repositoryArray( + response.diagnostics, + "repository error response.diagnostics", + ).map((item, index) => + repositoryApiDiagnostic( + item, + `repository error response.diagnostics[${index}]`, + ) + ), + error: repositoryString(response.error, "repository error response.error"), + message: repositoryString( + response.message, + "repository error response.message", + ), }; } diff --git a/web/workspace/src/lib/workspace/console/ConsoleLineItem.svelte b/web/workspace/src/lib/workspace/console/ConsoleLineItem.svelte index 2686304e9..ad631e467 100644 --- a/web/workspace/src/lib/workspace/console/ConsoleLineItem.svelte +++ b/web/workspace/src/lib/workspace/console/ConsoleLineItem.svelte @@ -133,7 +133,7 @@ {:else if item.kind === 'user'}
-
+
{:else if item.kind === 'activity'}

{item.body || '—'}

@@ -142,7 +142,7 @@ {:else if item.kind === 'run_stats'}

{item.body}

{:else if shouldRenderMarkdown(item)} - + {:else}

{item.body || '—'}

{/if} diff --git a/web/workspace/src/lib/workspace/console/MarkdownLink.svelte b/web/workspace/src/lib/workspace/console/MarkdownLink.svelte new file mode 100644 index 000000000..7f3f13ada --- /dev/null +++ b/web/workspace/src/lib/workspace/console/MarkdownLink.svelte @@ -0,0 +1,32 @@ + + +{#if safeHref} +
+ {@render children?.()} + +{:else} + {@render children?.()} +{/if} diff --git a/web/workspace/src/lib/workspace/console/RichMarkdown.browser.spec.ts b/web/workspace/src/lib/workspace/console/RichMarkdown.browser.spec.ts new file mode 100644 index 000000000..431917659 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/RichMarkdown.browser.spec.ts @@ -0,0 +1,209 @@ +// @vitest-environment happy-dom + +import { cleanup, render, waitFor } from "@testing-library/svelte"; +import { afterEach, expect, test } from "vitest"; +import RichMarkdown from "./RichMarkdown.svelte"; + +afterEach(cleanup); + +test("keeps completed block nodes while a trailing inline token grows", async () => { + const initial = [ + "# Stable heading", + "", + "This paragraph is already complete.", + "", + "- stable list item", + "- another item", + "", + "> stable quote", + "", + "```ts", + "const answer: number = 42;", + "```", + "", + "The final paragraph has **partial", + ].join("\n"); + const { container, rerender } = render(RichMarkdown, { + text: initial, + streamId: "message-1", + }); + + await waitFor(() => { + expect(container.querySelector("h1")?.textContent).toBe("Stable heading"); + expect(container.querySelector("ul")).not.toBeNull(); + expect(container.querySelector("blockquote")).not.toBeNull(); + expect(container.querySelector("pre.shiki code span")).not.toBeNull(); + }); + const stableNodes = [ + container.querySelector("h1"), + container.querySelector("p"), + container.querySelector("ul"), + container.querySelector("blockquote"), + container.querySelector("pre"), + ]; + + await rerender({ + text: `${initial} emphasis** and a [safe link](https://exam`, + streamId: "message-1", + }); + await waitFor(() => { + expect(container.querySelector("strong")?.textContent).toBe( + "partial emphasis", + ); + expect(container.textContent).toContain("[safe link](https://exam"); + }); + expect(container.querySelector('a[href="https://example.com"]')).toBeNull(); + expect(container.querySelector("h1")).toBe(stableNodes[0]); + expect(container.querySelector("p")).toBe(stableNodes[1]); + expect(container.querySelector("ul")).toBe(stableNodes[2]); + expect(container.querySelector("blockquote")).toBe(stableNodes[3]); + expect(container.querySelector("pre")).toBe(stableNodes[4]); + + await rerender({ + text: `${initial} emphasis** and a [safe link](https://example.com).`, + streamId: "message-1", + }); + await waitFor(() => { + expect(container.querySelector("strong")?.textContent).toBe( + "partial emphasis", + ); + expect(container.querySelector('a[href="https://example.com"]')).not + .toBeNull(); + }); + + expect(container.querySelector("h1")).toBe(stableNodes[0]); + expect(container.querySelector("p")).toBe(stableNodes[1]); + expect(container.querySelector("ul")).toBe(stableNodes[2]); + expect(container.querySelector("blockquote")).toBe(stableNodes[3]); + expect(container.querySelector("pre")).toBe(stableNodes[4]); +}); + +test("resets the DOM for non-prefix rewrites and stream identity changes", async () => { + const { container, rerender } = render(RichMarkdown, { + text: "# Original\n\nOriginal body.", + streamId: "message-1", + }); + await waitFor(() => { + expect(container.querySelector("h1")?.textContent).toBe("Original"); + }); + await rerender({ + text: "# Corrected\n\nRewritten body.", + streamId: "message-1", + }); + await waitFor(() => { + expect(container.querySelector("h1")?.textContent).toBe("Corrected"); + }); + expect(container.textContent).not.toContain("Original body."); + + await rerender({ + text: "# Corrected\n\nRewritten body for a new message.", + streamId: "message-2", + }); + await waitFor(() => { + expect(container.querySelector("p")?.textContent).toBe( + "Rewritten body for a new message.", + ); + }); + expect(container.querySelector("h1")?.textContent).toBe("Corrected"); + expect(container.textContent).not.toContain("Original body."); +}); + +test("preserves line breaks and the HTTP(S)-only link boundary", async () => { + const source = [ + "first line\nsecond line", + "", + "[https](https://example.com) [http](http://example.com)", + "[script](javascript:alert(1)) [data](data:text/html,hello)", + "[mail](mailto:test@example.com) [relative](/internal)", + "", + '', + ].join("\n"); + const { container } = render(RichMarkdown, { + text: source, + streamId: "message-security", + }); + + await waitFor(() => { + expect(container.querySelectorAll("a")).toHaveLength(2); + expect(container.querySelector("br")).not.toBeNull(); + }); + + const links = container.querySelectorAll("a"); + expect(links[0].getAttribute("href")).toBe("https://example.com"); + expect(links[1].getAttribute("href")).toBe("http://example.com"); + expect(links[0].getAttribute("target")).toBe("_blank"); + expect(links[0].getAttribute("rel")).toBe("noreferrer"); + expect(container.textContent).toContain("script"); + expect(container.textContent).toContain("relative"); + expect(container.querySelector("img")).toBeNull(); + expect(container.querySelector("script")).toBeNull(); +}); + +test("keeps prior content through an unclosed fence and unknown-language fallback", async () => { + const initial = [ + "Stable paragraph.", + "", + "```not-a-real-language", + "raw ", + ].join("\n"); + const { container, rerender } = render(RichMarkdown, { + text: initial, + streamId: "message-fence", + }); + await waitFor(() => { + expect(container.querySelector("pre.shiki-fallback code")?.textContent) + .toContain( + "raw ", + ); + }); + const stableParagraph = container.querySelector("p"); + + await rerender({ + text: `${initial}\n\`\`\`\n\nAfter the fence.`, + streamId: "message-fence", + }); + await waitFor(() => { + expect(container.querySelectorAll("p")).toHaveLength(2); + expect(container.querySelectorAll("p")[1]?.textContent).toBe( + "After the fence.", + ); + }); + expect(container.querySelector("p")).toBe(stableParagraph); + expect(container.querySelector("pre.shiki-fallback code")?.textContent) + .toContain( + "raw ", + ); +}); + +test("reuses early DOM nodes across a long streaming append", async () => { + const firstBatch = Array.from( + { length: 80 }, + (_, index) => `Paragraph ${index}.`, + ); + const { container, rerender } = render(RichMarkdown, { + text: firstBatch.join("\n\n"), + streamId: "message-long", + }); + await waitFor( + () => expect(container.querySelectorAll("p")).toHaveLength(80), + { timeout: 5_000 }, + ); + const first = container.querySelectorAll("p")[0]; + const middle = container.querySelectorAll("p")[40]; + + const secondBatch = Array.from( + { length: 40 }, + (_, index) => `Paragraph ${index + 80}.`, + ); + await rerender({ + text: [...firstBatch, ...secondBatch].join("\n\n"), + streamId: "message-long", + }); + await waitFor( + () => expect(container.querySelectorAll("p")).toHaveLength(120), + { timeout: 5_000 }, + ); + + expect(container.querySelectorAll("p")[0]).toBe(first); + expect(container.querySelectorAll("p")[40]).toBe(middle); +}, 10_000); diff --git a/web/workspace/src/lib/workspace/console/RichMarkdown.svelte b/web/workspace/src/lib/workspace/console/RichMarkdown.svelte index e9a56a718..5e7e16b05 100644 --- a/web/workspace/src/lib/workspace/console/RichMarkdown.svelte +++ b/web/workspace/src/lib/workspace/console/RichMarkdown.svelte @@ -1,41 +1,55 @@ + + -
- {#if html} - {@html html} - {:else} -

{text}

- {/if} +
+